From 287c47006c1c1f1375ffd88f6a72089c904fc0ff Mon Sep 17 00:00:00 2001 From: Yuna Date: Mon, 8 Jun 2026 15:04:39 +0200 Subject: [PATCH 1/3] Refactor workflow internals base (#90) * chore: polish release flow * refactor: simplify workflow internals * refactor: centralise workflow syntax routing * refactor: streamline workflow plugin internals * Refactor workflow internals into package boundaries (#91) * Remove final from class declarations --- .github/dependabot.yml | 25 +- .github/workflows/build.yml | 28 +- CHANGELOG.md | 19 + README.md | 33 +- build.gradle | 7 +- doc/adr/0011-release-once-publish-zip.md | 9 +- doc/navigation.md | 29 +- doc/spec/plugin-size-refactor-plan.md | 63 + gradle.properties | 2 +- .../entry/WorkflowAnnotator.java | 489 ++++++++ .../WorkflowCompletion.java} | 769 ++++++------ .../WorkflowDocumentationProvider.java | 60 +- .../git/RemoteActionProviders.java | 623 ++++++++++ .../githubworkflow/git/WorkflowLocation.java | 341 ++++++ .../helper/AutoPopupInsertHandler.java | 64 - .../githubworkflow/helper/FileDownloader.java | 123 -- .../helper/ListenerService.java | 22 - .../helper/PsiElementChangeListener.java | 39 - .../i18n/GitHubWorkflowBundle.java | 174 +++ .../githubworkflow/logic/GitHub.java | 39 - .../githubworkflow/logic/Runner.java | 30 - .../githubworkflow/logic/Strategy.java | 30 - .../model/CustomClickAction.java | 2 +- .../githubworkflow/model/GitHubAction.java | 219 ++-- .../model/GitHubSchemaProvider.java | 31 +- .../model/LocalActionReferenceResolver.java | 6 +- .../githubworkflow/model/SimpleElement.java | 6 +- .../githubworkflow/run/WorkflowRun.java | 1047 +++++++++++++++++ .../run/WorkflowRunConfiguration.java | 461 ++++++++ .../WorkflowRunProcessHandler.java | 699 +++++++---- .../WorkflowRunView.java} | 592 +++++++--- .../services/ClearActionCacheAction.java | 41 - .../services/ExpressionReferenceTarget.java | 7 - .../services/ExpressionReferenceTargets.java | 314 ----- .../services/FileIconProvider.java | 35 - .../services/GitHubRequestAuthorizations.java | 143 --- .../services/GitHubWorkflowBundle.java | 39 - .../services/HighlightAnnotator.java | 727 ------------ .../services/PluginErrorReportSubmitter.java | 84 -- .../services/PluginSettings.java | 64 - .../services/ProjectStartup.java | 108 -- .../services/ReferenceContributor.java | 77 -- .../services/RefreshActionCacheAction.java | 43 - .../services/RemoteActionProviders.java | 337 ------ .../services/RemoteActionResolution.java | 14 - .../services/RemoteServerSettings.java | 137 --- .../services/RestoreActionWarningsAction.java | 42 - .../services/SchemaProvider.java | 33 - .../WorkflowAutoPopupEnterHandler.java | 44 - .../WorkflowAutoPopupTypedHandler.java | 72 -- .../WorkflowCompletionConfidence.java | 28 - .../WorkflowCurrentBranchResolver.java | 86 -- .../services/WorkflowDispatchInputs.java | 226 ---- .../services/WorkflowRepository.java | 12 - .../services/WorkflowRepositoryResolver.java | 102 -- .../services/WorkflowRunClient.java | 658 ----------- .../services/WorkflowRunConfiguration.java | 178 --- .../WorkflowRunConfigurationProducer.java | 93 -- .../WorkflowRunConfigurationType.java | 70 -- .../services/WorkflowRunDownloads.java | 73 -- .../services/WorkflowRunJobConsole.java | 51 - .../services/WorkflowRunLanguageInjector.java | 221 ---- .../WorkflowRunLineMarkerContributor.java | 87 -- .../services/WorkflowRunLogRenderer.java | 209 ---- .../services/WorkflowRunRequest.java | 34 - .../services/WorkflowRunSettingsEditor.java | 143 --- .../services/WorkflowRunTracker.java | 65 - .../services/WorkflowSyntaxSchema.java | 328 ------ .../services/WorkflowTextAttributes.java | 31 - .../GitHubWorkflowSettingsConfigurable.java | 14 +- .../GitHubActionCache.java | 284 ++++- .../{logic => syntax}/Action.java | 46 +- .../{logic => syntax}/Envs.java | 32 +- .../{logic => syntax}/Inputs.java | 16 +- .../{logic => syntax}/JobContext.java | 30 +- .../{logic => syntax}/Jobs.java | 44 +- .../{logic => syntax}/Matrix.java | 26 +- .../{logic => syntax}/Needs.java | 44 +- .../{logic => syntax}/Secrets.java | 28 +- .../{logic => syntax}/Steps.java | 58 +- .../WorkflowAnnotations.java} | 26 +- .../WorkflowContextCatalog.java} | 35 +- .../WorkflowPsi.java} | 43 +- .../syntax/WorkflowReferences.java | 538 +++++++++ .../githubworkflow/syntax/WorkflowSyntax.java | 603 ++++++++++ .../WorkflowYaml.java} | 66 +- src/main/resources/META-INF/plugin.xml | 50 +- .../resources/github-docs/workflow-syntax.tsv | 296 +++++ src/main/resources/icons/gitea.svg | 8 + src/main/resources/icons/gitea_dark.svg | 8 + .../PluginWiringTest.java} | 59 +- .../RemoteActionProvidersTest.java | 52 +- .../git/WorkflowLocationTest.java | 89 ++ .../helper/FileDownloaderTest.java | 85 -- .../helper/GitHubWorkflowHelperTest.java | 50 - .../WorkflowMessagesTest.java} | 24 +- .../model/WorkflowCallableTest.java | 104 ++ .../WorkflowGutterTest.java} | 30 +- .../run/WorkflowPresentationTest.java | 738 ++++++++++++ .../run/WorkflowRunConfigurationTest.java | 86 ++ .../WorkflowRunProcessHandlerTest.java | 72 +- .../WorkflowRunTest.java} | 112 +- .../WorkflowRunViewTest.java} | 70 +- .../GitHubRequestAuthorizationsTest.java | 53 - .../PluginErrorReportSubmitterTest.java | 25 - .../services/SchemaResourcesTest.java | 38 - .../WorkflowCurrentBranchResolverTest.java | 40 - .../services/WorkflowDispatchInputsTest.java | 66 -- .../services/WorkflowDocumentationTest.java | 288 ----- .../services/WorkflowPerformanceTest.java | 49 - .../WorkflowRepositoryResolverTest.java | 58 - .../services/WorkflowRunConsoleTabsTest.java | 22 - .../WorkflowRunLanguageInjectionTest.java | 53 - .../WorkflowRunSettingsEditorTest.java | 25 - .../services/WorkflowStylingTest.java | 404 ------- .../GitHubActionCacheTest.java | 4 +- .../syntax/WorkflowFileIconTest.java | 62 + .../WorkflowLargeWorkflowTest.java} | 51 +- .../WorkflowMetadataTest.java} | 63 +- .../WorkflowQuickFixesTest.java} | 12 +- .../WorkflowReferencesTest.java} | 20 +- .../WorkflowSyntaxCompletionTest.java} | 40 +- .../WorkflowValidationTest.java} | 6 +- .../EditorFeatureTestCase.java | 14 +- .../{services => test}/FakeRemoteServer.java | 20 +- .../PluginExecutionPolicy.java | 2 +- 126 files changed, 8254 insertions(+), 8164 deletions(-) create mode 100644 doc/spec/plugin-size-refactor-plan.md create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java rename src/main/java/com/github/yunabraska/githubworkflow/{services/CodeCompletion.java => entry/WorkflowCompletion.java} (61%) rename src/main/java/com/github/yunabraska/githubworkflow/{services => entry}/WorkflowDocumentationProvider.java (91%) create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/git/WorkflowLocation.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/helper/AutoPopupInsertHandler.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/helper/FileDownloader.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/helper/ListenerService.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/helper/PsiElementChangeListener.java create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/i18n/GitHubWorkflowBundle.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/logic/GitHub.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/logic/Runner.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/logic/Strategy.java create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfiguration.java rename src/main/java/com/github/yunabraska/githubworkflow/{services => run}/WorkflowRunProcessHandler.java (56%) rename src/main/java/com/github/yunabraska/githubworkflow/{services/WorkflowRunConsoleTabs.java => run/WorkflowRunView.java} (66%) delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/ClearActionCacheAction.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTarget.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTargets.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/FileIconProvider.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizations.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/GitHubWorkflowBundle.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/HighlightAnnotator.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitter.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/PluginSettings.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/ProjectStartup.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/ReferenceContributor.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/RefreshActionCacheAction.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionProviders.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionResolution.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/RemoteServerSettings.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/RestoreActionWarningsAction.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/SchemaProvider.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupEnterHandler.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupTypedHandler.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCompletionConfidence.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolver.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputs.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepository.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolver.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunClient.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfiguration.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationProducer.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationType.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunDownloads.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunJobConsole.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjector.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLineMarkerContributor.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLogRenderer.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunRequest.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditor.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunTracker.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowSyntaxSchema.java delete mode 100644 src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowTextAttributes.java rename src/main/java/com/github/yunabraska/githubworkflow/{services => settings}/GitHubWorkflowSettingsConfigurable.java (95%) rename src/main/java/com/github/yunabraska/githubworkflow/{services => state}/GitHubActionCache.java (69%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Action.java (86%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Envs.java (77%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Inputs.java (74%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/JobContext.java (84%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Jobs.java (66%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Matrix.java (64%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Needs.java (77%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Secrets.java (76%) rename src/main/java/com/github/yunabraska/githubworkflow/{logic => syntax}/Steps.java (67%) rename src/main/java/com/github/yunabraska/githubworkflow/{helper/HighlightAnnotatorHelper.java => syntax/WorkflowAnnotations.java} (93%) rename src/main/java/com/github/yunabraska/githubworkflow/{helper/GitHubWorkflowConfig.java => syntax/WorkflowContextCatalog.java} (87%) rename src/main/java/com/github/yunabraska/githubworkflow/{helper/PsiElementHelper.java => syntax/WorkflowPsi.java} (92%) create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowReferences.java create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java rename src/main/java/com/github/yunabraska/githubworkflow/{helper/GitHubWorkflowHelper.java => syntax/WorkflowYaml.java} (82%) create mode 100644 src/main/resources/github-docs/workflow-syntax.tsv create mode 100644 src/main/resources/icons/gitea.svg create mode 100644 src/main/resources/icons/gitea_dark.svg rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowActionRegistrationTest.java => entry/PluginWiringTest.java} (60%) rename src/test/java/com/github/yunabraska/githubworkflow/{services => git}/RemoteActionProvidersTest.java (80%) create mode 100644 src/test/java/com/github/yunabraska/githubworkflow/git/WorkflowLocationTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/helper/FileDownloaderTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowHelperTest.java rename src/test/java/com/github/yunabraska/githubworkflow/{services/LocalizationResourcesTest.java => i18n/WorkflowMessagesTest.java} (93%) create mode 100644 src/test/java/com/github/yunabraska/githubworkflow/model/WorkflowCallableTest.java rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowGutterActionTest.java => run/WorkflowGutterTest.java} (88%) create mode 100644 src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java create mode 100644 src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfigurationTest.java rename src/test/java/com/github/yunabraska/githubworkflow/{services => run}/WorkflowRunProcessHandlerTest.java (92%) rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowRunClientTest.java => run/WorkflowRunTest.java} (75%) rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowRunLogRendererTest.java => run/WorkflowRunViewTest.java} (56%) delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizationsTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitterTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/SchemaResourcesTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolverTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputsTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDocumentationTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowPerformanceTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolverTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConsoleTabsTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjectionTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditorTest.java delete mode 100644 src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowStylingTest.java rename src/test/java/com/github/yunabraska/githubworkflow/{services => state}/GitHubActionCacheTest.java (98%) create mode 100644 src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowFileIconTest.java rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowShowcaseTest.java => syntax/WorkflowLargeWorkflowTest.java} (78%) rename src/test/java/com/github/yunabraska/githubworkflow/{helper/GitHubWorkflowConfigTest.java => syntax/WorkflowMetadataTest.java} (67%) rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowQuickFixTest.java => syntax/WorkflowQuickFixesTest.java} (97%) rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowReferenceTest.java => syntax/WorkflowReferencesTest.java} (97%) rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowCompletionTest.java => syntax/WorkflowSyntaxCompletionTest.java} (97%) rename src/test/java/com/github/yunabraska/githubworkflow/{services/WorkflowHighlightingTest.java => syntax/WorkflowValidationTest.java} (99%) rename src/test/java/com/github/yunabraska/githubworkflow/{services => test}/EditorFeatureTestCase.java (95%) rename src/test/java/com/github/yunabraska/githubworkflow/{services => test}/FakeRemoteServer.java (89%) rename src/test/java/com/github/yunabraska/githubworkflow/{services => test}/PluginExecutionPolicy.java (90%) 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; From 3f7014678a61bf077ef184c44f500114b7911c65 Mon Sep 17 00:00:00 2001 From: Yuna Morgenstern Date: Tue, 9 Jun 2026 22:56:04 +0200 Subject: [PATCH 2/3] Localize workflow syntax descriptions --- .../entry/WorkflowDocumentationProvider.java | 19 ++- .../githubworkflow/model/GitHubAction.java | 7 +- .../model/GitHubSchemaProvider.java | 7 +- .../githubworkflow/run/WorkflowRun.java | 2 +- .../GitHubWorkflowSettingsConfigurable.java | 8 +- .../githubworkflow/syntax/WorkflowSyntax.java | 35 ++-- src/main/resources/META-INF/plugin.xml | 5 +- .../messages/GitHubWorkflowBundle.properties | 11 +- .../GitHubWorkflowBundle_ar.properties | 21 +-- .../GitHubWorkflowBundle_cs.properties | 21 +-- .../GitHubWorkflowBundle_de.properties | 19 ++- .../GitHubWorkflowBundle_es.properties | 21 +-- .../GitHubWorkflowBundle_fr.properties | 23 +-- .../GitHubWorkflowBundle_hi.properties | 21 +-- .../GitHubWorkflowBundle_id.properties | 21 +-- .../GitHubWorkflowBundle_it.properties | 21 +-- .../GitHubWorkflowBundle_ja.properties | 21 +-- .../GitHubWorkflowBundle_ko.properties | 21 +-- .../GitHubWorkflowBundle_nl.properties | 21 +-- .../GitHubWorkflowBundle_pl.properties | 21 +-- .../GitHubWorkflowBundle_pt_BR.properties | 21 +-- .../GitHubWorkflowBundle_ru.properties | 21 +-- .../GitHubWorkflowBundle_sv.properties | 19 ++- .../GitHubWorkflowBundle_th.properties | 21 +-- .../GitHubWorkflowBundle_tr.properties | 21 +-- .../GitHubWorkflowBundle_uk.properties | 21 +-- .../GitHubWorkflowBundle_vi.properties | 21 +-- .../GitHubWorkflowBundle_zh_CN.properties | 21 +-- .../entry/PluginWiringTest.java | 27 +++ .../i18n/WorkflowMessagesTest.java | 159 +++++++++++++++++- .../run/WorkflowPresentationTest.java | 61 +++++++ .../syntax/WorkflowSyntaxCompletionTest.java | 28 +++ 32 files changed, 555 insertions(+), 232 deletions(-) diff --git a/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowDocumentationProvider.java b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowDocumentationProvider.java index 80d961e..b74fd61 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowDocumentationProvider.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowDocumentationProvider.java @@ -2,6 +2,8 @@ import com.github.yunabraska.githubworkflow.syntax.WorkflowReferences; +import com.github.yunabraska.githubworkflow.syntax.WorkflowSyntax; + import com.github.yunabraska.githubworkflow.state.GitHubActionCache; import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; @@ -77,6 +79,7 @@ public class WorkflowDocumentationProvider extends AbstractDocumentationProvider static Optional documentationAt(final PsiElement element, final int absoluteOffset) { return declaredOutputDoc(element) .or(() -> actionParameterDoc(element)) + .or(() -> workflowSyntaxDoc(element, absoluteOffset)) .or(() -> textElement(element).flatMap(WorkflowDocumentationProvider::actionUseDoc)) .or(() -> textElement(element).flatMap(text -> variableDoc(text, absoluteOffset))); } @@ -173,6 +176,20 @@ private static Optional declaredOutputDoc(final PsiElement element) .map(output -> outputDoc(outputLabel(output), output.getKeyText(), output)); } + private static Optional workflowSyntaxDoc(final PsiElement element, final int absoluteOffset) { + return keyValueAt(element) + .filter(keyValue -> isKeyOffset(keyValue, absoluteOffset)) + .flatMap(keyValue -> WorkflowSyntax.descriptionForKey(keyValue) + .map(description -> simpleDoc(message("documentation.workflowSyntax.label"), keyValue.getKeyText(), description))); + } + + private static boolean isKeyOffset(final YAMLKeyValue keyValue, final int absoluteOffset) { + return ofNullable(keyValue.getKey()) + .map(PsiElement::getTextRange) + .filter(range -> range.containsOffset(absoluteOffset)) + .isPresent(); + } + private static boolean isDirectOutput(final YAMLKeyValue output) { return output.getParent() != null && output.getParent().getParent() instanceof YAMLKeyValue parent @@ -552,7 +569,7 @@ public String getText() { @Override public String toString() { - return "GitHub workflow documentation"; + return message("documentation.provider"); } } } 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 9fd4d6f..5bbd90e 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,8 @@ package com.github.yunabraska.githubworkflow.model; -import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; @@ -179,7 +180,7 @@ public Map freshInputs() { if (isLocal()) { extractLocalParameters(); } - return concatMap(inputs, ignoredInputs.stream().filter(WorkflowPsi::hasText).collect(Collectors.toMap(key -> key, value -> "*** manual added input ***"))); + return concatMap(inputs, ignoredInputs.stream().filter(WorkflowPsi::hasText).collect(Collectors.toMap(key -> key, value -> GitHubWorkflowBundle.message("documentation.manualInput")))); } public Map freshOutputs() { @@ -190,7 +191,7 @@ public Map freshOutputs(final boolean withIgnoredItems) { if (isLocal()) { extractLocalParameters(); } - return withIgnoredItems ? concatMap(outputs, ignoredOutputs.stream().filter(WorkflowPsi::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 -> GitHubWorkflowBundle.message("documentation.manualOutput")))) : unmodifiableMap(outputs); } public Map freshSecrets() { 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 88acf18..4dc3ba0 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubSchemaProvider.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubSchemaProvider.java @@ -1,5 +1,6 @@ package com.github.yunabraska.githubworkflow.model; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.intellij.json.JsonFileType; import com.intellij.openapi.vfs.VirtualFile; @@ -32,7 +33,7 @@ public GitHubSchemaProvider(final String schemaName, final String displayName, f @NotNull @Override public String getName() { - return displayName; + return GitHubWorkflowBundle.message("schema.auto", displayName); } @Override @@ -57,12 +58,12 @@ public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final GitHubSchemaProvider that = (GitHubSchemaProvider) o; - return Objects.equals(getName(), that.getName()); + return Objects.equals(schemaName, that.schemaName); } @Override public int hashCode() { - return Objects.hash(getName()); + return Objects.hash(schemaName); } private String schemaContent() { diff --git a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java index cc54b9e..397ac53 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java @@ -421,7 +421,7 @@ private static String responseSummary(final int statusCode, final HttpHeaders he .orElse("") .toLowerCase(); if (contentType.contains("text/html") || body.startsWith(" LOCALES = List.of( new LocaleOption(GitHubWorkflowBundle.Settings.SYSTEM_LANGUAGE, "settings.language.system", true), - new LocaleOption("ar", "Arabic"), - new LocaleOption("cs", "Czech"), + new LocaleOption("ar", "العربية"), + new LocaleOption("cs", "Čeština"), new LocaleOption("de", "Deutsch"), new LocaleOption("es", "Español"), new LocaleOption("fr", "Français"), - new LocaleOption("hi", "Hindi"), - new LocaleOption("id", "Indonesia"), + new LocaleOption("hi", "हिन्दी"), + new LocaleOption("id", "Bahasa Indonesia"), new LocaleOption("it", "Italiano"), new LocaleOption("ja", "日本語"), new LocaleOption("ko", "한국어"), diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java index 71f5242..e13cb28 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java @@ -56,14 +56,14 @@ 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) + new GitHubSchemaProvider("dependabot-2.0", "Dependabot", WorkflowYaml::isDependabotFile), + new GitHubSchemaProvider("github-action", "GitHub Action", WorkflowYaml::isActionFile), + new GitHubSchemaProvider("github-funding", "GitHub Funding", WorkflowYaml::isFoundingFile), + new GitHubSchemaProvider("github-workflow", "GitHub Workflow", WorkflowYaml::isWorkflowFile), + new GitHubSchemaProvider("github-discussion", "GitHub Discussion", WorkflowYaml::isDiscussionFile), + new GitHubSchemaProvider("github-issue-forms", "GitHub Issue Forms", WorkflowYaml::isIssueForms), + new GitHubSchemaProvider("github-issue-config", "GitHub Workflow Issue Template configuration", WorkflowYaml::isIssueConfigFile), + new GitHubSchemaProvider("github-workflow-template-properties", "GitHub Workflow Template Properties", WorkflowYaml::isWorkflowTemplatePropertiesFile) ) .distinct() .toList(); @@ -389,6 +389,15 @@ public static Optional> completionKeysForPath(final List descriptionForKey(final YAMLKeyValue keyValue) { + return WorkflowLocation.from(keyValue) + .filter(WorkflowLocation::workflowFile) + .flatMap(location -> knownKeysForPath(location.path(), true) + .map(KnownKeys::values) + .map(values -> values.get(location.keyValue().getKeyText()))) + .filter(value -> !value.isBlank()); + } + public static Optional validationKeysForPath(final List path) { return knownKeysForPath(path, false); } @@ -519,7 +528,13 @@ public static Map runnerLabels() { } private static Map table(final String group) { - return Tables.DATA.getOrDefault(group, Collections.emptyMap()); + final Map keys = Tables.DATA.getOrDefault(group, Collections.emptyMap()); + if (keys.isEmpty()) { + return Collections.emptyMap(); + } + final Map result = new LinkedHashMap<>(); + keys.forEach((key, bundleKey) -> result.put(key, GitHubWorkflowBundle.message(bundleKey))); + return Collections.unmodifiableMap(result); } private static Map> loadTables() { @@ -556,7 +571,7 @@ private static void loadTableLine(final Map> result, throw new IllegalStateException("Invalid " + WORKFLOW_SYNTAX_RESOURCE + " line " + lineNumber); } result.computeIfAbsent(parts[0], ignored -> new LinkedHashMap<>()) - .put(parts[1], GitHubWorkflowBundle.message(parts[2])); + .put(parts[1], parts[2]); } private static Map> immutableTables(final Map> source) { diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 09e0fbb..9f76a32 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,7 +1,7 @@ com.github.yunabraska.githubworkflowplugin - Github Workflow + GitHub Workflow Yuna Morgenstern messages.GitHubWorkflowBundle @@ -53,7 +53,8 @@ + key="settings.displayName" + bundle="messages.GitHubWorkflowBundle"/> diff --git a/src/main/resources/messages/GitHubWorkflowBundle.properties b/src/main/resources/messages/GitHubWorkflowBundle.properties index 2771e50..7de3c27 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle.properties @@ -1,7 +1,6 @@ -plugin.name=GitHub Workflow -plugin.description=Support for GitHub Actions workflow files group.GitHubWorkflow.Tools.text=GitHub Workflow group.GitHubWorkflow.Tools.description=GitHub Workflow plugin tools +schema.auto={0} [Auto] action.GitHubWorkflow.RefreshActionCache.text=Refresh Action Cache action.GitHubWorkflow.RefreshActionCache.description=Refresh resolved remote GitHub Actions and reusable workflow metadata action.GitHubWorkflow.RestoreActionWarnings.text=Restore Action Warnings @@ -26,6 +25,7 @@ workflow.run.error.repository=GitHub repository owner and name are required. workflow.run.error.workflow=Workflow file is required. workflow.run.error.ref=Branch or tag ref is required. workflow.run.error.inputs=GitHub workflow_dispatch supports at most 25 inputs. +workflow.run.error.html=GitHub returned an HTML error page instead of API data. workflow.run.gutter.stop=Stop workflow run workflow.run.gutter.stop.text=Stop workflow run workflow.run.gutter.stop.description=Cancel this run @@ -113,6 +113,7 @@ documentation.servicePort.label=Service port documentation.container.label=Job container documentation.symbol.label=Workflow symbol documentation.symbol.description=Resolved workflow expression. +documentation.workflowSyntax.label=Workflow key documentation.workflowOutput.label=Workflow output documentation.jobOutput.label=Job output documentation.action.label=Action @@ -120,6 +121,9 @@ documentation.externalAction.label=External action documentation.reusableWorkflow.label=Reusable workflow documentation.resolvedFrom=resolved from {0} documentation.notResolved=not resolved yet +documentation.manualInput=Manually added input +documentation.manualOutput=Manually added output +documentation.provider=GitHub workflow documentation documentation.inputs.title=Inputs documentation.outputs.title=Outputs documentation.secrets.title=Secrets @@ -217,7 +221,6 @@ completion.uses.local.action=Local action completion.uses.ref.known=Known workflow reference completion.uses.ref.remote=Remote workflow reference completion.uses.remote.known=Known remote action or reusable workflow -completion.workflow.syntax=GitHub Actions workflow syntax completion.workflow.top.name=Workflow display name completion.workflow.top.run-name=Dynamic run name completion.workflow.top.on=Events that start the workflow @@ -402,12 +405,10 @@ settings.cache.import.done=Imported cache entries. The archive beast behaved. settings.cache.import.unsupported=Unsupported GitHub Workflow cache file. settings.cache.import.brokenLine=Broken GitHub Workflow cache line. settings.cache.import.brokenKey=Broken GitHub Workflow cache key. -settings.support.button=Support this plugin settings.support.tooltip=Open the support page settings.support.line.0=Feed the build furnace settings.support.line.1=Buy the parser coffee settings.support.line.2=Sponsor fewer haunted workflows -workflow.run.jobs.title=Workflow jobs workflow.run.jobs.root=Workflow run workflow.run.jobs.description=GitHub Actions job tree and selected job log workflow.run.tree.done=done diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ar.properties b/src/main/resources/messages/GitHubWorkflowBundle_ar.properties index a371bad..b31bd60 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ar.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ar.properties @@ -1,15 +1,14 @@ -plugin.name=سير العمل GitHub -plugin.description=دعم ملفات سير عمل إجراءات GitHub group.GitHubWorkflow.Tools.text=سير العمل GitHub group.GitHubWorkflow.Tools.description=GitHub أدوات البرنامج المساعد لسير العمل -action.GitHubWorkflow.RefreshActionCache.text=Refresh ذاكرة التخزين المؤقت للعمل -action.GitHubWorkflow.RefreshActionCache.description=قام Refresh بحل إجراءات GitHub عن بعد وبيانات تعريف سير العمل القابلة لإعادة الاستخدام +schema.auto={0} [تلقائي] +action.GitHubWorkflow.RefreshActionCache.text=تحديث ذاكرة الإجراءات المؤقتة +action.GitHubWorkflow.RefreshActionCache.description=تحديث بيانات إجراءات GitHub البعيدة المحلولة وبيانات سير العمل القابلة لإعادة الاستخدام action.GitHubWorkflow.RestoreActionWarnings.text=استعادة تحذيرات العمل action.GitHubWorkflow.RestoreActionWarnings.description=استعادة تحذيرات التحقق من صحة الإجراءات والمدخلات والمخرجات action.GitHubWorkflow.ClearActionCache.text=مسح ذاكرة التخزين المؤقت للعمل action.GitHubWorkflow.ClearActionCache.description=امسح إجراءات GitHub المخزنة مؤقتًا وبيانات تعريف سير العمل القابلة لإعادة الاستخدام notification.cache.cleared=تم مسح إدخالات سير عمل {0} المخزنة مؤقتًا. -notification.cache.refresh.started=Refreshing {0} قام بتخزين إدخالات سير العمل GitHub عن بعد مؤقتًا. +notification.cache.refresh.started=جارٍ تحديث {0} إدخالًا بعيدًا مخزنًا مؤقتًا لسير عمل GitHub. notification.warnings.restored=تمت استعادة التحذيرات لإدخالات سير العمل {0} GitHub. workflow.run.configuration.display=سير العمل GitHub workflow.run.configuration.description=إرسال ومتابعة سير عمل إجراءات GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=مطلوب اسم ومالك مستودع GitHub. workflow.run.error.workflow=مطلوب ملف سير العمل. workflow.run.error.ref=مطلوب فرع أو علامة المرجع. workflow.run.error.inputs=GitHub يدعم workflow_dispatch 25 مدخلاً على الأكثر. +workflow.run.error.html=أعاد GitHub صفحة خطأ HTML بدلًا من بيانات API. workflow.run.gutter.stop=إيقاف تشغيل سير العمل workflow.run.gutter.stop.text=إيقاف تشغيل سير العمل workflow.run.gutter.stop.description=إلغاء هذا التشغيل -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=الإعدادات > التحكم بالإصدارات > GitHub workflow.log.command=يجري: workflow.log.warning=تحذير: workflow.log.error=خطأ: @@ -113,6 +113,7 @@ documentation.servicePort.label=منفذ الخدمة documentation.container.label=حاوية الوظيفة documentation.symbol.label=رمز سير العمل documentation.symbol.description=تم حل تعبير سير العمل. +documentation.workflowSyntax.label=مفتاح سير العمل documentation.workflowOutput.label=إخراج سير العمل documentation.jobOutput.label=مخرجات الوظيفة documentation.action.label=العمل @@ -120,6 +121,9 @@ documentation.externalAction.label=العمل الخارجي documentation.reusableWorkflow.label=سير العمل القابل لإعادة الاستخدام documentation.resolvedFrom=تم حلها من {0} documentation.notResolved=لم يتم حلها بعد +documentation.manualInput=إدخال مضاف يدويًا +documentation.manualOutput=مخرج مضاف يدويًا +documentation.provider=توثيق سير عمل GitHub documentation.inputs.title=المدخلات documentation.outputs.title=النواتج documentation.secrets.title=أسرار @@ -217,7 +221,6 @@ completion.uses.local.action=العمل المحلي completion.uses.ref.known=مرجع سير العمل المعروف completion.uses.ref.remote=مرجع سير العمل عن بعد completion.uses.remote.known=الإجراء عن بعد المعروف أو سير العمل القابل لإعادة الاستخدام -completion.workflow.syntax=GitHub بناء جملة سير العمل completion.workflow.top.name=اسم عرض سير العمل completion.workflow.top.run-name=اسم التشغيل الديناميكي completion.workflow.top.on=الأحداث التي تبدأ سير العمل @@ -387,7 +390,7 @@ settings.cache.state.resolved=حلها settings.cache.state.pending=قيد الانتظار settings.cache.state.expired=قديمة settings.cache.state.suppressed=قمعت -settings.cache.refresh=جدول Refresh +settings.cache.refresh=تحديث الجدول settings.cache.deleteSelected=حذف المحدد settings.cache.deleteAll=احذف الكل settings.cache.export=يصدّر @@ -402,12 +405,10 @@ settings.cache.import.done=إدخالات ذاكرة التخزين المؤقت settings.cache.import.unsupported=ملف ذاكرة التخزين المؤقت لسير العمل GitHub غير مدعوم. settings.cache.import.brokenLine=خط ذاكرة التخزين المؤقت لسير العمل GitHub معطل. settings.cache.import.brokenKey=مفتاح ذاكرة التخزين المؤقت لسير العمل GitHub معطل. -settings.support.button=دعم هذا البرنامج المساعد settings.support.tooltip=افتح صفحة الدعم settings.support.line.0=تغذية فرن البناء settings.support.line.1=شراء القهوة المحلل settings.support.line.2=رعاية عدد أقل من سير العمل المسكون -workflow.run.jobs.title=وظائف سير العمل workflow.run.jobs.root=تشغيل سير العمل workflow.run.jobs.description=شجرة وظائف إجراءات GitHub وسجل الوظائف المحدد workflow.run.tree.done=منتهي diff --git a/src/main/resources/messages/GitHubWorkflowBundle_cs.properties b/src/main/resources/messages/GitHubWorkflowBundle_cs.properties index 4e571f2..cf43d86 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_cs.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_cs.properties @@ -1,15 +1,14 @@ -plugin.name=Pracovní postup GitHub -plugin.description=Podpora souborů pracovního postupu akcí GitHub group.GitHubWorkflow.Tools.text=Pracovní postup GitHub group.GitHubWorkflow.Tools.description=Nástroje pluginu GitHub Workflow -action.GitHubWorkflow.RefreshActionCache.text=Obnovit cache akcí -action.GitHubWorkflow.RefreshActionCache.description=Refresh vyřešené vzdálené akce GitHub a znovu použitelná metadata pracovního postupu +schema.auto={0} [Automaticky] +action.GitHubWorkflow.RefreshActionCache.text=Obnovit mezipaměť akcí +action.GitHubWorkflow.RefreshActionCache.description=Obnovit metadata vyřešených vzdálených akcí GitHub a opakovaně použitelných workflow action.GitHubWorkflow.RestoreActionWarnings.text=Obnovit varování akce action.GitHubWorkflow.RestoreActionWarnings.description=Obnovte potlačená upozornění na ověření platnosti vstupu a výstupu action.GitHubWorkflow.ClearActionCache.text=Vymažte mezipaměť akcí action.GitHubWorkflow.ClearActionCache.description=Vymažte akce GitHub uložené v mezipaměti a znovu použitelná metadata pracovního postupu notification.cache.cleared=Vymazány položky GitHub Workflow {0} uložené v mezipaměti. -notification.cache.refresh.started=Refreshing {0} uložil do mezipaměti vzdálené položky pracovního postupu GitHub. +notification.cache.refresh.started=Obnovuji {0} vzdálených položek GitHub Workflow v mezipaměti. notification.warnings.restored=Obnovena varování pro položky pracovního postupu {0} GitHub. workflow.run.configuration.display=Pracovní postup GitHub workflow.run.configuration.description=Odešlete a sledujte běh pracovního postupu akcí GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=Je vyžadováno jméno a vlastník úložiště Gi workflow.run.error.workflow=Je vyžadován soubor pracovního postupu. workflow.run.error.ref=Je vyžadováno označení větve nebo značky. workflow.run.error.inputs=GitHub workflow_dispatch podporuje maximálně 25 vstupů. +workflow.run.error.html=GitHub vrátil chybovou stránku HTML místo dat API. workflow.run.gutter.stop=Zastavit běh pracovního postupu workflow.run.gutter.stop.text=Zastavit běh pracovního postupu workflow.run.gutter.stop.description=Zrušte tento běh -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Nastavení > Správa verzí > GitHub workflow.log.command=spustit: workflow.log.warning=varování: workflow.log.error=chyba: @@ -113,6 +113,7 @@ documentation.servicePort.label=Servisní port documentation.container.label=Pracovní kontejner documentation.symbol.label=Symbol pracovního postupu documentation.symbol.description=Vyřešený výraz workflow. +documentation.workflowSyntax.label=Klíč workflow documentation.workflowOutput.label=Výstup pracovního postupu documentation.jobOutput.label=Výstup úlohy documentation.action.label=Akce @@ -120,6 +121,9 @@ documentation.externalAction.label=Vnější působení documentation.reusableWorkflow.label=Opakovaně použitelný pracovní postup documentation.resolvedFrom=vyřešeno z {0} documentation.notResolved=zatím nevyřešeno +documentation.manualInput=Ručně přidaný vstup +documentation.manualOutput=Ručně přidaný výstup +documentation.provider=Dokumentace workflow GitHub documentation.inputs.title=Vstupy documentation.outputs.title=Výstupy documentation.secrets.title=Tajemství @@ -217,7 +221,6 @@ completion.uses.local.action=Místní akce completion.uses.ref.known=Známý odkaz na pracovní postup completion.uses.ref.remote=Vzdálená reference pracovního postupu completion.uses.remote.known=Známá vzdálená akce nebo opakovaně použitelný pracovní postup -completion.workflow.syntax=Syntaxe pracovního postupu akcí GitHub completion.workflow.top.name=Zobrazovaný název pracovního postupu completion.workflow.top.run-name=Název dynamického běhu completion.workflow.top.on=Události, které spouštějí pracovní postup @@ -387,7 +390,7 @@ settings.cache.state.resolved=vyřešeno settings.cache.state.pending=čeká na vyřízení settings.cache.state.expired=zatuchlý settings.cache.state.suppressed=potlačeno -settings.cache.refresh=Refresh stůl +settings.cache.refresh=Obnovit tabulku settings.cache.deleteSelected=Smazat vybrané settings.cache.deleteAll=Smazat vše settings.cache.export=Exportovat @@ -402,12 +405,10 @@ settings.cache.import.done=Importované položky mezipaměti. Archivní bestie s settings.cache.import.unsupported=Nepodporovaný soubor mezipaměti GitHub Workflow. settings.cache.import.brokenLine=Přerušený řádek mezipaměti GitHub Workflow. settings.cache.import.brokenKey=Poškozený klíč mezipaměti GitHub Workflow. -settings.support.button=Podporujte tento plugin settings.support.tooltip=Otevřete stránku podpory settings.support.line.0=Naplňte stavební pec settings.support.line.1=Kupte si analyzátorovou kávu settings.support.line.2=Sponzorujte méně strašidelných pracovních postupů -workflow.run.jobs.title=Pracovní toky workflow.run.jobs.root=Spuštění pracovního postupu workflow.run.jobs.description=Strom úloh GitHub Actions a vybraný protokol úloh workflow.run.tree.done=hotovo diff --git a/src/main/resources/messages/GitHubWorkflowBundle_de.properties b/src/main/resources/messages/GitHubWorkflowBundle_de.properties index f4997a3..681c372 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_de.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_de.properties @@ -1,7 +1,6 @@ -plugin.name=GitHub-Workflow -plugin.description=Unterstützung für GitHub Actions-Workflowdateien group.GitHubWorkflow.Tools.text=GitHub-Workflow group.GitHubWorkflow.Tools.description=GitHub Workflow-Plugin-Tools +schema.auto={0} [Automatisch] action.GitHubWorkflow.RefreshActionCache.text=Action-Cache aktualisieren action.GitHubWorkflow.RefreshActionCache.description=Metadaten aufgelöster entfernter GitHub Actions und wiederverwendbarer Workflows aktualisieren action.GitHubWorkflow.RestoreActionWarnings.text=Aktionswarnungen wiederherstellen @@ -9,7 +8,7 @@ action.GitHubWorkflow.RestoreActionWarnings.description=Unterdrückte Aktions-, action.GitHubWorkflow.ClearActionCache.text=Aktionscache leeren action.GitHubWorkflow.ClearActionCache.description=Löschen Sie zwischengespeicherte GitHub-Aktionen und wiederverwendbare Workflow-Metadaten notification.cache.cleared={0} zwischengespeicherte GitHub-Workflow-Einträge gelöscht. -notification.cache.refresh.started=Refreshing {0} zwischengespeicherte Remote-GitHub-Workflow-Einträge. +notification.cache.refresh.started={0} zwischengespeicherte Remote-GitHub-Workflow-Einträge werden aktualisiert. notification.warnings.restored=Warnungen für {0} GitHub Workflow-Einträge wiederhergestellt. workflow.run.configuration.display=GitHub-Workflow workflow.run.configuration.description=Verteilen und befolgen Sie die Workflow-Ausführungen von GitHub-Aktionen @@ -26,10 +25,11 @@ workflow.run.error.repository=Besitzer und Name des GitHub-Repositorys sind erfo workflow.run.error.workflow=Workflow-Datei ist erforderlich. workflow.run.error.ref=Branch- oder Tag-Referenz ist erforderlich. workflow.run.error.inputs=GitHub workflow_dispatch unterstützt maximal 25 Eingänge. +workflow.run.error.html=GitHub gab eine HTML-Fehlerseite statt API-Daten zurück. workflow.run.gutter.stop=Stoppen Sie die Workflow-Ausführung workflow.run.gutter.stop.text=Stoppen Sie die Workflow-Ausführung workflow.run.gutter.stop.description=Brechen Sie diesen Lauf ab -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Einstellungen > Versionskontrolle > GitHub workflow.log.command=Lauf: workflow.log.warning=Warnung: workflow.log.error=Fehler: @@ -113,6 +113,7 @@ documentation.servicePort.label=Service-Port documentation.container.label=Jobcontainer documentation.symbol.label=Workflow-Symbol documentation.symbol.description=Workflow-Ausdruck behoben. +documentation.workflowSyntax.label=Workflow-Schlüssel documentation.workflowOutput.label=Workflow-Ausgabe documentation.jobOutput.label=Auftragsausgabe documentation.action.label=Aktion @@ -120,6 +121,9 @@ documentation.externalAction.label=Äußeres Handeln documentation.reusableWorkflow.label=Wiederverwendbarer Workflow documentation.resolvedFrom=behoben von {0} documentation.notResolved=noch nicht gelöst +documentation.manualInput=Manuell hinzugefügte Eingabe +documentation.manualOutput=Manuell hinzugefügte Ausgabe +documentation.provider=GitHub-Workflow-Dokumentation documentation.inputs.title=Eingaben documentation.outputs.title=Ausgänge documentation.secrets.title=Geheimnisse @@ -217,7 +221,6 @@ completion.uses.local.action=Lokale Aktion completion.uses.ref.known=Bekannte Workflow-Referenz completion.uses.ref.remote=Referenz zum Remote-Workflow completion.uses.remote.known=Bekannte Remote-Aktion oder wiederverwendbarer Workflow -completion.workflow.syntax=GitHub Actions-Workflow-Syntax completion.workflow.top.name=Anzeigename des Workflows completion.workflow.top.run-name=Dynamischer Laufname completion.workflow.top.on=Ereignisse, die den Workflow starten @@ -387,7 +390,7 @@ settings.cache.state.resolved=gelöst settings.cache.state.pending=ausstehend settings.cache.state.expired=abgestanden settings.cache.state.suppressed=unterdrückt -settings.cache.refresh=Refresh-Tabelle +settings.cache.refresh=Tabelle aktualisieren settings.cache.deleteSelected=Ausgewählte löschen settings.cache.deleteAll=Alles löschen settings.cache.export=Exportieren @@ -402,12 +405,10 @@ settings.cache.import.done=Importierte Cache-Einträge. Das Archivbiest benahm s settings.cache.import.unsupported=Nicht unterstützte GitHub-Workflow-Cache-Datei. settings.cache.import.brokenLine=Defekte GitHub-Workflow-Cache-Zeile. settings.cache.import.brokenKey=Defekter GitHub-Workflow-Cache-Schlüssel. -settings.support.button=Unterstützen Sie dieses Plugin -settings.support.tooltip=Öffnen Sie die Support-Seite +settings.support.tooltip=Support-Seite öffnen settings.support.line.0=Beschicken Sie den Bauofen settings.support.line.1=Kaufen Sie den Parser-Kaffee settings.support.line.2=Fördern Sie weniger hektische Arbeitsabläufe -workflow.run.jobs.title=Workflow-Jobs workflow.run.jobs.root=Workflow-Ausführung workflow.run.jobs.description=GitHub Aktionen Jobbaum und ausgewähltes Jobprotokoll workflow.run.tree.done=erledigt diff --git a/src/main/resources/messages/GitHubWorkflowBundle_es.properties b/src/main/resources/messages/GitHubWorkflowBundle_es.properties index 1a47e7b..b3d79c8 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_es.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_es.properties @@ -1,15 +1,14 @@ -plugin.name=Flujo de trabajo GitHub -plugin.description=Compatibilidad con archivos de flujo de trabajo de acciones GitHub group.GitHubWorkflow.Tools.text=Flujo de trabajo GitHub group.GitHubWorkflow.Tools.description=Herramientas complementarias de flujo de trabajo GitHub -action.GitHubWorkflow.RefreshActionCache.text=Refresh caché de acciones -action.GitHubWorkflow.RefreshActionCache.description=Refresh resolvió acciones GitHub remotas y metadatos de flujo de trabajo reutilizables +schema.auto={0} [Automático] +action.GitHubWorkflow.RefreshActionCache.text=Actualizar caché de acciones +action.GitHubWorkflow.RefreshActionCache.description=Actualizar metadatos de acciones GitHub remotas resueltas y workflows reutilizables action.GitHubWorkflow.RestoreActionWarnings.text=Restaurar advertencias de acción action.GitHubWorkflow.RestoreActionWarnings.description=Restaurar advertencias de validación de acciones, entradas y salidas suprimidas action.GitHubWorkflow.ClearActionCache.text=Borrar caché de acciones action.GitHubWorkflow.ClearActionCache.description=Borrar acciones GitHub almacenadas en caché y metadatos de flujo de trabajo reutilizables notification.cache.cleared=Se borraron las entradas del flujo de trabajo GitHub en caché de {0}. -notification.cache.refresh.started=Refreshing {0} almacena en caché las entradas remotas del flujo de trabajo GitHub. +notification.cache.refresh.started=Actualizando {0} entradas remotas de GitHub Workflow en caché. notification.warnings.restored=Advertencias restauradas para entradas de flujo de trabajo {0} GitHub. workflow.run.configuration.display=Flujo de trabajo GitHub workflow.run.configuration.description=Distribuir y seguir las ejecuciones del flujo de trabajo de acciones GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=Se requieren el propietario y el nombre del reposi workflow.run.error.workflow=Se requiere un archivo de flujo de trabajo. workflow.run.error.ref=Se requiere referencia de sucursal o etiqueta. workflow.run.error.inputs=GitHub workflow_dispatch admite como máximo 25 entradas. +workflow.run.error.html=GitHub devolvió una página de error HTML en lugar de datos de API. workflow.run.gutter.stop=Detener la ejecución del flujo de trabajo workflow.run.gutter.stop.text=Detener la ejecución del flujo de trabajo workflow.run.gutter.stop.description=Cancelar esta ejecución -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Configuración > Control de versiones > GitHub workflow.log.command=ejecutar: workflow.log.warning=advertencia: workflow.log.error=error: @@ -113,6 +113,7 @@ documentation.servicePort.label=Puerto de servicio documentation.container.label=contenedor de trabajo documentation.symbol.label=Símbolo de flujo de trabajo documentation.symbol.description=Expresión de flujo de trabajo resuelta. +documentation.workflowSyntax.label=Clave de workflow documentation.workflowOutput.label=Salida del flujo de trabajo documentation.jobOutput.label=Salida del trabajo documentation.action.label=acción @@ -120,6 +121,9 @@ documentation.externalAction.label=Acción exterior documentation.reusableWorkflow.label=Flujo de trabajo reutilizable documentation.resolvedFrom=resuelto desde {0} documentation.notResolved=aún no resuelto +documentation.manualInput=Entrada añadida manualmente +documentation.manualOutput=Salida añadida manualmente +documentation.provider=Documentación del flujo de trabajo GitHub documentation.inputs.title=Entradas documentation.outputs.title=Salidas documentation.secrets.title=Secretos @@ -217,7 +221,6 @@ completion.uses.local.action=Acción local completion.uses.ref.known=Referencia de flujo de trabajo conocida completion.uses.ref.remote=Referencia de flujo de trabajo remoto completion.uses.remote.known=Acción remota conocida o flujo de trabajo reutilizable -completion.workflow.syntax=Sintaxis del flujo de trabajo de acciones GitHub completion.workflow.top.name=Nombre para mostrar del flujo de trabajo completion.workflow.top.run-name=Nombre de ejecución dinámica completion.workflow.top.on=Eventos que inician el flujo de trabajo @@ -387,7 +390,7 @@ settings.cache.state.resolved=resuelto settings.cache.state.pending=pendiente settings.cache.state.expired=rancio settings.cache.state.suppressed=reprimido -settings.cache.refresh=Tabla Refresh +settings.cache.refresh=Actualizar tabla settings.cache.deleteSelected=Eliminar seleccionado settings.cache.deleteAll=eliminar todo settings.cache.export=Exportar @@ -402,12 +405,10 @@ settings.cache.import.done=Entradas de caché importadas. La bestia del archivo settings.cache.import.unsupported=Archivo de caché de flujo de trabajo GitHub no compatible. settings.cache.import.brokenLine=Línea de caché del flujo de trabajo GitHub rota. settings.cache.import.brokenKey=Clave de caché del flujo de trabajo GitHub rota. -settings.support.button=Admite este complemento settings.support.tooltip=Abra la página de soporte settings.support.line.0=Alimentar el horno de construcción settings.support.line.1=Compra el café analizador. settings.support.line.2=Patrocine menos flujos de trabajo embrujados -workflow.run.jobs.title=Trabajos de flujo de trabajo workflow.run.jobs.root=Ejecución del flujo de trabajo workflow.run.jobs.description=GitHub Árbol de trabajos de acciones y registro de trabajos seleccionados workflow.run.tree.done=hecho diff --git a/src/main/resources/messages/GitHubWorkflowBundle_fr.properties b/src/main/resources/messages/GitHubWorkflowBundle_fr.properties index 8a66e19..6b3d2c6 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_fr.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_fr.properties @@ -1,15 +1,14 @@ -plugin.name=Flux de travail GitHub -plugin.description=Prise en charge des fichiers de workflow d''actions GitHub group.GitHubWorkflow.Tools.text=Flux de travail GitHub group.GitHubWorkflow.Tools.description=Outils du plug-in GitHub Workflow -action.GitHubWorkflow.RefreshActionCache.text=Cache d''actions Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh a résolu les actions GitHub à distance et les métadonnées de flux de travail réutilisables +schema.auto={0} [Automatique] +action.GitHubWorkflow.RefreshActionCache.text=Actualiser le cache des actions +action.GitHubWorkflow.RefreshActionCache.description=Actualiser les actions GitHub distantes résolues et les métadonnées de workflows réutilisables action.GitHubWorkflow.RestoreActionWarnings.text=Avertissements relatifs aux actions de restauration action.GitHubWorkflow.RestoreActionWarnings.description=Restaurer les avertissements de validation d''action, d''entrée et de sortie supprimés action.GitHubWorkflow.ClearActionCache.text=Effacer le cache des actions action.GitHubWorkflow.ClearActionCache.description=Effacer les actions GitHub mises en cache et les métadonnées de flux de travail réutilisables notification.cache.cleared=Suppression des entrées de flux de travail GitHub mises en cache par {0}. -notification.cache.refresh.started=Refreshing {0} a mis en cache les entrées de flux de travail GitHub distantes. +notification.cache.refresh.started=Actualisation de {0} entrées distantes GitHub Workflow en cache. notification.warnings.restored=Avertissements restaurés pour les entrées de workflow {0} GitHub. workflow.run.configuration.display=Flux de travail GitHub workflow.run.configuration.description=Répartir et suivre les exécutions du workflow Actions GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=Le propriétaire et le nom du référentiel GitHub workflow.run.error.workflow=Le fichier de flux de travail est requis. workflow.run.error.ref=Une référence de branche ou de balise est requise. workflow.run.error.inputs=GitHub workflow_dispatch prend en charge au maximum 25 entrées. +workflow.run.error.html=GitHub a renvoyé une page d’erreur HTML au lieu des données API. workflow.run.gutter.stop=Arrêter l''exécution du workflow workflow.run.gutter.stop.text=Arrêter l''exécution du workflow workflow.run.gutter.stop.description=Annuler cette course -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Paramètres > Contrôle de version > GitHub workflow.log.command=exécuter : workflow.log.warning=avertissement : workflow.log.error=erreur : @@ -113,6 +113,7 @@ documentation.servicePort.label=Port de service documentation.container.label=Conteneur de tâches documentation.symbol.label=Symbole de flux de travail documentation.symbol.description=Expression de flux de travail résolue. +documentation.workflowSyntax.label=Clé de workflow documentation.workflowOutput.label=Sortie du flux de travail documentation.jobOutput.label=Résultat du travail documentation.action.label=Action GitHub @@ -120,6 +121,9 @@ documentation.externalAction.label=Action extérieure documentation.reusableWorkflow.label=Flux de travail réutilisable documentation.resolvedFrom=résolu à partir de {0} documentation.notResolved=pas encore résolu +documentation.manualInput=Entrée ajoutée manuellement +documentation.manualOutput=Sortie ajoutée manuellement +documentation.provider=Documentation du workflow GitHub documentation.inputs.title=Entrées documentation.outputs.title=Sorties documentation.secrets.title=Secrets GitHub @@ -217,7 +221,6 @@ completion.uses.local.action=Action locale completion.uses.ref.known=Référence de workflow connue completion.uses.ref.remote=Référence du workflow à distance completion.uses.remote.known=Action à distance connue ou workflow réutilisable -completion.workflow.syntax=Syntaxe du flux de travail des actions GitHub completion.workflow.top.name=Nom d''affichage du flux de travail completion.workflow.top.run-name=Nom d''exécution dynamique completion.workflow.top.on=Événements qui démarrent le flux de travail @@ -387,7 +390,7 @@ settings.cache.state.resolved=résolu settings.cache.state.pending=en attente settings.cache.state.expired=périmé settings.cache.state.suppressed=supprimé -settings.cache.refresh=Table Refresh +settings.cache.refresh=Actualiser le tableau settings.cache.deleteSelected=Supprimer la sélection settings.cache.deleteAll=Supprimer tout settings.cache.export=Exporter @@ -402,12 +405,10 @@ settings.cache.import.done=Entrées de cache importées. La bête des archives s settings.cache.import.unsupported=Fichier cache de flux de travail GitHub non pris en charge. settings.cache.import.brokenLine=Ligne de cache du flux de travail GitHub cassée. settings.cache.import.brokenKey=Clé de cache du flux de travail GitHub cassée. -settings.support.button=Supporte ce plugin -settings.support.tooltip=Ouvrez la page d''assistance +settings.support.tooltip=Ouvrir la page de soutien settings.support.line.0=Alimenter le four de construction settings.support.line.1=Acheter le café analyseur settings.support.line.2=Parrainez moins de flux de travail hantés -workflow.run.jobs.title=Tâches de flux de travail workflow.run.jobs.root=Exécution du flux de travail workflow.run.jobs.description=GitHub Arborescence des tâches d''actions et journal des tâches sélectionnées workflow.run.tree.done=fait diff --git a/src/main/resources/messages/GitHubWorkflowBundle_hi.properties b/src/main/resources/messages/GitHubWorkflowBundle_hi.properties index 1c8debc..65f6b80 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_hi.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_hi.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub वर्कफ़्लो -plugin.description=GitHub क्रियाएँ वर्कफ़्लो फ़ाइलों के लिए समर्थन group.GitHubWorkflow.Tools.text=GitHub वर्कफ़्लो group.GitHubWorkflow.Tools.description=GitHub वर्कफ़्लो प्लगइन उपकरण -action.GitHubWorkflow.RefreshActionCache.text=Refresh एक्शन कैश -action.GitHubWorkflow.RefreshActionCache.description=Refresh ने दूरस्थ GitHub क्रियाओं और पुन: प्रयोज्य वर्कफ़्लो मेटाडेटा का समाधान किया +schema.auto={0} [ऑटो] +action.GitHubWorkflow.RefreshActionCache.text=एक्शन कैश ताज़ा करें +action.GitHubWorkflow.RefreshActionCache.description=हल की गई दूरस्थ GitHub Actions और पुन: प्रयोज्य वर्कफ़्लो मेटाडेटा ताज़ा करें action.GitHubWorkflow.RestoreActionWarnings.text=कार्रवाई चेतावनियाँ पुनर्स्थापित करें action.GitHubWorkflow.RestoreActionWarnings.description=दबाई गई कार्रवाई, इनपुट और आउटपुट सत्यापन चेतावनियों को पुनर्स्थापित करें action.GitHubWorkflow.ClearActionCache.text=एक्शन कैश साफ़ करें action.GitHubWorkflow.ClearActionCache.description=कैश्ड GitHub क्रियाएँ और पुन: प्रयोज्य वर्कफ़्लो मेटाडेटा साफ़ करें notification.cache.cleared={0} कैश्ड GitHub वर्कफ़्लो प्रविष्टियाँ साफ़ की गईं। -notification.cache.refresh.started=Refreshing {0} कैश्ड रिमोट GitHub वर्कफ़्लो प्रविष्टियाँ। +notification.cache.refresh.started=कैश की गई {0} दूरस्थ GitHub Workflow प्रविष्टियाँ ताज़ा की जा रही हैं। notification.warnings.restored={0} GitHub वर्कफ़्लो प्रविष्टियों के लिए पुनर्स्थापित चेतावनियाँ। workflow.run.configuration.display=GitHub वर्कफ़्लो workflow.run.configuration.description=प्रेषण और GitHub क्रियाएँ वर्कफ़्लो रन का पालन करें @@ -26,10 +25,11 @@ workflow.run.error.repository=GitHub रिपॉजिटरी स्वाम workflow.run.error.workflow=वर्कफ़्लो फ़ाइल आवश्यक है. workflow.run.error.ref=शाखा या टैग रेफरी आवश्यक है. workflow.run.error.inputs=GitHub workflow_dispatch अधिकतम 25 इनपुट का समर्थन करता है। +workflow.run.error.html=GitHub ने API डेटा के बजाय HTML त्रुटि पृष्ठ लौटाया. workflow.run.gutter.stop=वर्कफ़्लो चलाना बंद करें workflow.run.gutter.stop.text=वर्कफ़्लो चलाना बंद करें workflow.run.gutter.stop.description=यह रन रद्द करें -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=सेटिंग्स > संस्करण नियंत्रण > GitHub workflow.log.command=चलाएँ: workflow.log.warning=चेतावनी: workflow.log.error=त्रुटि: @@ -113,6 +113,7 @@ documentation.servicePort.label=सेवा बंदरगाह documentation.container.label=जॉब कंटेनर documentation.symbol.label=वर्कफ़्लो प्रतीक documentation.symbol.description=समाधानित वर्कफ़्लो अभिव्यक्ति. +documentation.workflowSyntax.label=वर्कफ़्लो कुंजी documentation.workflowOutput.label=वर्कफ़्लो आउटपुट documentation.jobOutput.label=नौकरी आउटपुट documentation.action.label=कार्रवाई @@ -120,6 +121,9 @@ documentation.externalAction.label=बाह्य क्रिया documentation.reusableWorkflow.label=पुन: प्रयोज्य कार्यप्रवाह documentation.resolvedFrom={0} से हल किया गया documentation.notResolved=अभी तक हल नहीं हुआ +documentation.manualInput=मैन्युअल रूप से जोड़ा गया इनपुट +documentation.manualOutput=मैन्युअल रूप से जोड़ा गया आउटपुट +documentation.provider=GitHub वर्कफ़्लो दस्तावेज़ documentation.inputs.title=इनपुट documentation.outputs.title=आउटपुट documentation.secrets.title=रहस्य @@ -217,7 +221,6 @@ completion.uses.local.action=स्थानीय कार्रवाई completion.uses.ref.known=ज्ञात वर्कफ़्लो संदर्भ completion.uses.ref.remote=दूरस्थ कार्यप्रवाह संदर्भ completion.uses.remote.known=ज्ञात दूरस्थ क्रिया या पुन: प्रयोज्य वर्कफ़्लो -completion.workflow.syntax=GitHub क्रियाएँ वर्कफ़्लो सिंटैक्स completion.workflow.top.name=वर्कफ़्लो प्रदर्शन नाम completion.workflow.top.run-name=गतिशील रन नाम completion.workflow.top.on=वे घटनाएँ जो वर्कफ़्लो प्रारंभ करती हैं @@ -387,7 +390,7 @@ settings.cache.state.resolved=समाधान हो गया settings.cache.state.pending=लंबित settings.cache.state.expired=बासी settings.cache.state.suppressed=दबा दिया गया -settings.cache.refresh=Refresh तालिका +settings.cache.refresh=तालिका ताज़ा करें settings.cache.deleteSelected=चयनित हटाएँ settings.cache.deleteAll=सभी हटाएं settings.cache.export=निर्यात करें @@ -402,12 +405,10 @@ settings.cache.import.done=आयातित कैश प्रविष्ट settings.cache.import.unsupported=असमर्थित GitHub वर्कफ़्लो कैश फ़ाइल। settings.cache.import.brokenLine=टूटी हुई GitHub वर्कफ़्लो कैश लाइन। settings.cache.import.brokenKey=टूटी हुई GitHub वर्कफ़्लो कैश कुंजी। -settings.support.button=इस प्लगइन का समर्थन करें settings.support.tooltip=सहायता पृष्ठ खोलें settings.support.line.0=बिल्ड फर्नेस को खिलाएं settings.support.line.1=पार्सर कॉफ़ी खरीदें settings.support.line.2=कम प्रेतवाधित वर्कफ़्लो को प्रायोजित करें -workflow.run.jobs.title=कार्यप्रवाह नौकरियाँ workflow.run.jobs.root=वर्कफ़्लो चलाएँ workflow.run.jobs.description=GitHub क्रियाएँ जॉब ट्री और चयनित जॉब लॉग workflow.run.tree.done=किया diff --git a/src/main/resources/messages/GitHubWorkflowBundle_id.properties b/src/main/resources/messages/GitHubWorkflowBundle_id.properties index e333d8d..5115258 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_id.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_id.properties @@ -1,15 +1,14 @@ -plugin.name=Alur Kerja GitHub -plugin.description=Dukungan untuk file alur kerja Tindakan GitHub group.GitHubWorkflow.Tools.text=Alur Kerja GitHub group.GitHubWorkflow.Tools.description=Alat plugin Alur Kerja GitHub -action.GitHubWorkflow.RefreshActionCache.text=Cache Tindakan Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh menyelesaikan Tindakan GitHub jarak jauh dan metadata alur kerja yang dapat digunakan kembali +schema.auto={0} [Otomatis] +action.GitHubWorkflow.RefreshActionCache.text=Segarkan cache tindakan +action.GitHubWorkflow.RefreshActionCache.description=Segarkan metadata GitHub Actions jarak jauh yang sudah terselesaikan dan workflow yang dapat digunakan ulang action.GitHubWorkflow.RestoreActionWarnings.text=Kembalikan Peringatan Tindakan action.GitHubWorkflow.RestoreActionWarnings.description=Pulihkan peringatan validasi tindakan, input, dan output yang disembunyikan action.GitHubWorkflow.ClearActionCache.text=Hapus Cache Tindakan action.GitHubWorkflow.ClearActionCache.description=Hapus Tindakan GitHub yang di-cache dan metadata alur kerja yang dapat digunakan kembali notification.cache.cleared=Menghapus entri Alur Kerja GitHub yang di-cache {0}. -notification.cache.refresh.started=Refreshing {0} menyimpan cache entri Alur Kerja GitHub jarak jauh. +notification.cache.refresh.started=Menyegarkan {0} entri GitHub Workflow jarak jauh dalam cache. notification.warnings.restored=Peringatan yang dipulihkan untuk entri Alur Kerja {0} GitHub. workflow.run.configuration.display=Alur Kerja GitHub workflow.run.configuration.description=Kirim dan ikuti alur kerja Tindakan GitHub yang berjalan @@ -26,10 +25,11 @@ workflow.run.error.repository=Pemilik dan nama repositori GitHub wajib diisi. workflow.run.error.workflow=File alur kerja diperlukan. workflow.run.error.ref=Diperlukan referensi cabang atau tag. workflow.run.error.inputs=GitHub workflow_dispatch mendukung maksimal 25 input. +workflow.run.error.html=GitHub mengembalikan halaman galat HTML, bukan data API. workflow.run.gutter.stop=Hentikan alur kerja yang dijalankan workflow.run.gutter.stop.text=Hentikan alur kerja yang dijalankan workflow.run.gutter.stop.description=Batalkan proses ini -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Pengaturan > Kontrol Versi > GitHub workflow.log.command=menjalankan: workflow.log.warning=peringatan: workflow.log.error=kesalahan: @@ -113,6 +113,7 @@ documentation.servicePort.label=Pelabuhan layanan documentation.container.label=Wadah pekerjaan documentation.symbol.label=Simbol alur kerja documentation.symbol.description=Ekspresi alur kerja terselesaikan. +documentation.workflowSyntax.label=Kunci workflow documentation.workflowOutput.label=Keluaran alur kerja documentation.jobOutput.label=Keluaran pekerjaan documentation.action.label=Tindakan @@ -120,6 +121,9 @@ documentation.externalAction.label=Tindakan eksternal documentation.reusableWorkflow.label=Alur kerja yang dapat digunakan kembali documentation.resolvedFrom=diselesaikan dari {0} documentation.notResolved=belum terselesaikan +documentation.manualInput=Input ditambahkan manual +documentation.manualOutput=Output ditambahkan manual +documentation.provider=Dokumentasi alur kerja GitHub documentation.inputs.title=masukan documentation.outputs.title=Keluaran documentation.secrets.title=Rahasia @@ -217,7 +221,6 @@ completion.uses.local.action=Tindakan lokal completion.uses.ref.known=Referensi alur kerja yang diketahui completion.uses.ref.remote=Referensi alur kerja jarak jauh completion.uses.remote.known=Tindakan jarak jauh yang diketahui atau alur kerja yang dapat digunakan kembali -completion.workflow.syntax=Sintaks alur kerja Tindakan GitHub completion.workflow.top.name=Nama tampilan alur kerja completion.workflow.top.run-name=Nama proses dinamis completion.workflow.top.on=Peristiwa yang memulai alur kerja @@ -387,7 +390,7 @@ settings.cache.state.resolved=terselesaikan settings.cache.state.pending=tertunda settings.cache.state.expired=basi settings.cache.state.suppressed=ditekan -settings.cache.refresh=Tabel Refresh +settings.cache.refresh=Segarkan tabel settings.cache.deleteSelected=Hapus yang dipilih settings.cache.deleteAll=Hapus semua settings.cache.export=Ekspor @@ -402,12 +405,10 @@ settings.cache.import.done=Entri cache yang diimpor. Binatang arsip itu berperil settings.cache.import.unsupported=File cache Alur Kerja GitHub tidak didukung. settings.cache.import.brokenLine=Baris cache alur kerja GitHub rusak. settings.cache.import.brokenKey=Kunci cache alur kerja GitHub rusak. -settings.support.button=Dukung plugin ini settings.support.tooltip=Buka halaman dukungan settings.support.line.0=Beri makan tungku pembangunan settings.support.line.1=Beli kopi parser settings.support.line.2=Mensponsori lebih sedikit alur kerja yang berhantu -workflow.run.jobs.title=Pekerjaan alur kerja workflow.run.jobs.root=Alur kerja dijalankan workflow.run.jobs.description=Pohon pekerjaan Tindakan GitHub dan log pekerjaan yang dipilih workflow.run.tree.done=selesai diff --git a/src/main/resources/messages/GitHubWorkflowBundle_it.properties b/src/main/resources/messages/GitHubWorkflowBundle_it.properties index 7822206..8f26b11 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_it.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_it.properties @@ -1,15 +1,14 @@ -plugin.name=Flusso di lavoro GitHub -plugin.description=Supporto per i file del flusso di lavoro delle azioni GitHub group.GitHubWorkflow.Tools.text=Flusso di lavoro GitHub group.GitHubWorkflow.Tools.description=GitHub Strumenti plug-in del flusso di lavoro -action.GitHubWorkflow.RefreshActionCache.text=Refresh Cache delle azioni -action.GitHubWorkflow.RefreshActionCache.description=Refresh ha risolto le azioni GitHub remote e i metadati del flusso di lavoro riutilizzabili +schema.auto={0} [Automatico] +action.GitHubWorkflow.RefreshActionCache.text=Aggiorna cache azioni +action.GitHubWorkflow.RefreshActionCache.description=Aggiorna metadati delle azioni GitHub remote risolte e dei workflow riutilizzabili action.GitHubWorkflow.RestoreActionWarnings.text=Ripristina avvisi di azioni action.GitHubWorkflow.RestoreActionWarnings.description=Ripristina gli avvisi di convalida di azioni, input e output soppressi action.GitHubWorkflow.ClearActionCache.text=Cancella cache delle azioni action.GitHubWorkflow.ClearActionCache.description=Cancella le azioni GitHub memorizzate nella cache e i metadati del flusso di lavoro riutilizzabili notification.cache.cleared=Cancellate le voci del flusso di lavoro GitHub memorizzate nella cache di {0}. -notification.cache.refresh.started=Refreshing {0} ha memorizzato nella cache le voci del flusso di lavoro GitHub remote. +notification.cache.refresh.started=Aggiornamento di {0} voci remote GitHub Workflow nella cache. notification.warnings.restored=Avvisi ripristinati per {0} GitHub Voci del flusso di lavoro. workflow.run.configuration.display=Flusso di lavoro GitHub workflow.run.configuration.description=Invia e segui le esecuzioni del flusso di lavoro Azioni GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=Il proprietario e il nome del repository GitHub so workflow.run.error.workflow=Il file del flusso di lavoro è obbligatorio. workflow.run.error.ref=Il riferimento al ramo o al tag è obbligatorio. workflow.run.error.inputs=GitHub workflow_dispatch supporta al massimo 25 ingressi. +workflow.run.error.html=GitHub ha restituito una pagina di errore HTML invece dei dati API. workflow.run.gutter.stop=Arresta l''esecuzione del flusso di lavoro workflow.run.gutter.stop.text=Arresta l''esecuzione del flusso di lavoro workflow.run.gutter.stop.description=Annulla questa corsa -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Impostazioni > Controllo versione > GitHub workflow.log.command=correre: workflow.log.warning=avvertimento: workflow.log.error=errore: @@ -113,6 +113,7 @@ documentation.servicePort.label=Porto di servizio documentation.container.label=Contenitore di lavoro documentation.symbol.label=Simbolo del flusso di lavoro documentation.symbol.description=Espressione del flusso di lavoro risolta. +documentation.workflowSyntax.label=Chiave workflow documentation.workflowOutput.label=Output del flusso di lavoro documentation.jobOutput.label=Produzione di lavoro documentation.action.label=Azione @@ -120,6 +121,9 @@ documentation.externalAction.label=Azione esterna documentation.reusableWorkflow.label=Flusso di lavoro riutilizzabile documentation.resolvedFrom=risolto da {0} documentation.notResolved=non ancora risolto +documentation.manualInput=Input aggiunto manualmente +documentation.manualOutput=Output aggiunto manualmente +documentation.provider=Documentazione del workflow GitHub documentation.inputs.title=Ingressi documentation.outputs.title=Uscite documentation.secrets.title=Segreti @@ -217,7 +221,6 @@ completion.uses.local.action=Azione locale completion.uses.ref.known=Riferimento al flusso di lavoro noto completion.uses.ref.remote=Riferimento al flusso di lavoro remoto completion.uses.remote.known=Azione remota nota o flusso di lavoro riutilizzabile -completion.workflow.syntax=GitHub Sintassi del flusso di lavoro delle azioni completion.workflow.top.name=Nome visualizzato del flusso di lavoro completion.workflow.top.run-name=Nome della corsa dinamica completion.workflow.top.on=Eventi che avviano il flusso di lavoro @@ -387,7 +390,7 @@ settings.cache.state.resolved=risolto settings.cache.state.pending=in sospeso settings.cache.state.expired=stantio settings.cache.state.suppressed=soppresso -settings.cache.refresh=Tabella Refresh +settings.cache.refresh=Aggiorna tabella settings.cache.deleteSelected=Elimina selezionato settings.cache.deleteAll=Elimina tutto settings.cache.export=Esportazione @@ -402,12 +405,10 @@ settings.cache.import.done=Voci della cache importate. La bestia dell''archivio settings.cache.import.unsupported=File di cache del flusso di lavoro GitHub non supportato. settings.cache.import.brokenLine=Riga della cache del flusso di lavoro GitHub interrotta. settings.cache.import.brokenKey=Chiave cache del flusso di lavoro GitHub danneggiata. -settings.support.button=Supporta questo plugin settings.support.tooltip=Apri la pagina di supporto settings.support.line.0=Alimenta il forno di costruzione settings.support.line.1=Compra il caffè del parser settings.support.line.2=Sponsorizza meno flussi di lavoro infestati -workflow.run.jobs.title=Lavori del flusso di lavoro workflow.run.jobs.root=Esecuzione del flusso di lavoro workflow.run.jobs.description=GitHub Albero dei lavori delle azioni e registro dei lavori selezionati workflow.run.tree.done=fatto diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ja.properties b/src/main/resources/messages/GitHubWorkflowBundle_ja.properties index 9d5c7d9..0b7923f 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ja.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ja.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub ワークフロー -plugin.description=GitHub アクション ワークフロー ファイルのサポート group.GitHubWorkflow.Tools.text=GitHub ワークフロー group.GitHubWorkflow.Tools.description=GitHub ワークフロー プラグイン ツール -action.GitHubWorkflow.RefreshActionCache.text=Refresh アクション キャッシュ -action.GitHubWorkflow.RefreshActionCache.description=Refresh が解決したリモート GitHub アクションと再利用可能なワークフロー メタデータ +schema.auto={0} [自動] +action.GitHubWorkflow.RefreshActionCache.text=アクションキャッシュを更新 +action.GitHubWorkflow.RefreshActionCache.description=解決済みのリモート GitHub Actions と再利用可能なワークフローメタデータを更新 action.GitHubWorkflow.RestoreActionWarnings.text=復元アクションの警告 action.GitHubWorkflow.RestoreActionWarnings.description=抑制されたアクション、入力、および出力の検証警告を復元します action.GitHubWorkflow.ClearActionCache.text=アクションキャッシュのクリア action.GitHubWorkflow.ClearActionCache.description=キャッシュされた GitHub アクションと再利用可能なワークフロー メタデータをクリアします notification.cache.cleared={0} キャッシュされた GitHub ワークフロー エントリをクリアしました。 -notification.cache.refresh.started={0} キャッシュされたリモート GitHub ワークフロー エントリを更新しています。 +notification.cache.refresh.started=キャッシュ済みのリモート GitHub Workflow エントリ {0} 件を更新中。 notification.warnings.restored={0} GitHub ワークフロー エントリの警告が復元されました。 workflow.run.configuration.display=GitHub ワークフロー workflow.run.configuration.description=GitHub アクション ワークフローの実行をディスパッチして追跡する @@ -26,10 +25,11 @@ workflow.run.error.repository=GitHub リポジトリの所有者と名前は必 workflow.run.error.workflow=ワークフローファイルが必要です。 workflow.run.error.ref=ブランチまたはタグ参照が必要です。 workflow.run.error.inputs=GitHub workflow_dispatch は最大 25 入力をサポートします。 +workflow.run.error.html=GitHub は API データではなく HTML エラーページを返しました。 workflow.run.gutter.stop=ワークフローの実行を停止する workflow.run.gutter.stop.text=ワークフローの実行を停止する workflow.run.gutter.stop.description=この実行をキャンセルする -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=設定 > バージョン管理 > GitHub workflow.log.command=実行: workflow.log.warning=警告: workflow.log.error=エラー: @@ -113,6 +113,7 @@ documentation.servicePort.label=サービスポート documentation.container.label=ジョブコンテナ documentation.symbol.label=ワークフローのシンボル documentation.symbol.description=解決されたワークフロー式。 +documentation.workflowSyntax.label=ワークフローキー documentation.workflowOutput.label=ワークフローの出力 documentation.jobOutput.label=ジョブの出力 documentation.action.label=アクション @@ -120,6 +121,9 @@ documentation.externalAction.label=外部アクション documentation.reusableWorkflow.label=再利用可能なワークフロー documentation.resolvedFrom={0} から解決されました documentation.notResolved=まだ解決されていない +documentation.manualInput=手動追加された入力 +documentation.manualOutput=手動追加された出力 +documentation.provider=GitHub ワークフローのドキュメント documentation.inputs.title=入力 documentation.outputs.title=出力 documentation.secrets.title=秘密 @@ -217,7 +221,6 @@ completion.uses.local.action=ローカルアクション completion.uses.ref.known=既知のワークフローのリファレンス completion.uses.ref.remote=リモートワークフローリファレンス completion.uses.remote.known=既知のリモート アクションまたは再利用可能なワークフロー -completion.workflow.syntax=GitHub アクションのワークフロー構文 completion.workflow.top.name=ワークフローの表示名 completion.workflow.top.run-name=動的実行名 completion.workflow.top.on=ワークフローを開始するイベント @@ -387,7 +390,7 @@ settings.cache.state.resolved=解決済み settings.cache.state.pending=保留中 settings.cache.state.expired=古い settings.cache.state.suppressed=抑制された -settings.cache.refresh=Refresh テーブル +settings.cache.refresh=テーブルを更新 settings.cache.deleteSelected=選択したものを削除 settings.cache.deleteAll=すべて削除 settings.cache.export=エクスポート @@ -402,12 +405,10 @@ settings.cache.import.done=インポートされたキャッシュ エントリ settings.cache.import.unsupported=サポートされていない GitHub ワークフロー キャッシュ ファイルです。 settings.cache.import.brokenLine=GitHub ワークフロー キャッシュ ラインが壊れています。 settings.cache.import.brokenKey=GitHub ワークフロー キャッシュ キーが壊れています。 -settings.support.button=このプラグインをサポートする settings.support.tooltip=サポートページを開く settings.support.line.0=ビルド炉に供給する settings.support.line.1=パーサーコーヒーを購入する settings.support.line.2=スポンサーとなって幽霊の出るワークフローを減らす -workflow.run.jobs.title=ワークフロージョブ workflow.run.jobs.root=ワークフローの実行 workflow.run.jobs.description=GitHub アクションのジョブ ツリーと選択されたジョブ ログ workflow.run.tree.done=完了しました diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ko.properties b/src/main/resources/messages/GitHubWorkflowBundle_ko.properties index e7aa7fb..3a0389b 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ko.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ko.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub 작업 흐름 -plugin.description=GitHub 작업 워크플로 파일 지원 group.GitHubWorkflow.Tools.text=GitHub 작업흐름 group.GitHubWorkflow.Tools.description=GitHub 워크플로우 플러그인 도구 -action.GitHubWorkflow.RefreshActionCache.text=Refresh 액션 캐시 -action.GitHubWorkflow.RefreshActionCache.description=Refresh는 원격 GitHub 작업 및 재사용 가능한 워크플로 메타데이터를 해결했습니다. +schema.auto={0} [자동] +action.GitHubWorkflow.RefreshActionCache.text=액션 캐시 새로 고침 +action.GitHubWorkflow.RefreshActionCache.description=해결된 원격 GitHub Actions 및 재사용 가능한 워크플로 메타데이터 새로 고침 action.GitHubWorkflow.RestoreActionWarnings.text=복원 작업 경고 action.GitHubWorkflow.RestoreActionWarnings.description=억제된 작업, 입력 및 출력 유효성 검사 경고를 복원합니다. action.GitHubWorkflow.ClearActionCache.text=액션 캐시 지우기 action.GitHubWorkflow.ClearActionCache.description=캐시된 GitHub 작업 및 재사용 가능한 워크플로 메타데이터 지우기 notification.cache.cleared={0} 캐시된 GitHub 워크플로 항목을 지웠습니다. -notification.cache.refresh.started=Refreshing {0}는 원격 GitHub 워크플로 항목을 캐시했습니다. +notification.cache.refresh.started=캐시된 원격 GitHub Workflow 항목 {0}개 새로 고침 중. notification.warnings.restored={0} GitHub 워크플로 항목에 대한 경고가 복원되었습니다. workflow.run.configuration.display=GitHub 작업 흐름 workflow.run.configuration.description=GitHub Actions 워크플로우 실행을 디스패치하고 따르십시오. @@ -26,10 +25,11 @@ workflow.run.error.repository=GitHub 저장소 소유자 및 이름이 필요합 workflow.run.error.workflow=워크플로 파일이 필요합니다. workflow.run.error.ref=분기 또는 태그 참조가 필요합니다. workflow.run.error.inputs=GitHub workflow_dispatch는 최대 25개의 입력을 지원합니다. +workflow.run.error.html=GitHub가 API 데이터 대신 HTML 오류 페이지를 반환했습니다. workflow.run.gutter.stop=워크플로 실행 중지 workflow.run.gutter.stop.text=워크플로 실행 중지 workflow.run.gutter.stop.description=이 실행 취소 -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=설정 > 버전 관리 > GitHub workflow.log.command=실행: workflow.log.warning=경고: workflow.log.error=오류: @@ -113,6 +113,7 @@ documentation.servicePort.label=서비스 포트 documentation.container.label=작업 컨테이너 documentation.symbol.label=워크플로 기호 documentation.symbol.description=워크플로 표현이 해결되었습니다. +documentation.workflowSyntax.label=워크플로 키 documentation.workflowOutput.label=워크플로 출력 documentation.jobOutput.label=작업 출력 documentation.action.label=액션 @@ -120,6 +121,9 @@ documentation.externalAction.label=외부 조치 documentation.reusableWorkflow.label=재사용 가능한 워크플로 documentation.resolvedFrom={0}에서 해결됨 documentation.notResolved=아직 해결되지 않았습니다 +documentation.manualInput=수동으로 추가한 입력 +documentation.manualOutput=수동으로 추가한 출력 +documentation.provider=GitHub 워크플로 문서 documentation.inputs.title=입력 documentation.outputs.title=출력 documentation.secrets.title=비밀 @@ -217,7 +221,6 @@ completion.uses.local.action=지역 활동 completion.uses.ref.known=알려진 워크플로 참조 completion.uses.ref.remote=원격 워크플로 참조 completion.uses.remote.known=알려진 원격 작업 또는 재사용 가능한 작업 흐름 -completion.workflow.syntax=GitHub 작업 워크플로 구문 completion.workflow.top.name=워크플로 표시 이름 completion.workflow.top.run-name=동적 실행 이름 completion.workflow.top.on=워크플로를 시작하는 이벤트 @@ -387,7 +390,7 @@ settings.cache.state.resolved=해결됨 settings.cache.state.pending=보류 중 settings.cache.state.expired=부실한 settings.cache.state.suppressed=억압된 -settings.cache.refresh=Refresh 테이블 +settings.cache.refresh=표 새로 고침 settings.cache.deleteSelected=선택 항목 삭제 settings.cache.deleteAll=모두 삭제 settings.cache.export=수출 @@ -402,12 +405,10 @@ settings.cache.import.done=가져온 캐시 항목입니다. 아카이브 짐승 settings.cache.import.unsupported=지원되지 않는 GitHub 워크플로 캐시 파일입니다. settings.cache.import.brokenLine=GitHub 워크플로 캐시 라인이 손상되었습니다. settings.cache.import.brokenKey=GitHub 워크플로 캐시 키가 손상되었습니다. -settings.support.button=이 플러그인 지원 settings.support.tooltip=지원 페이지 열기 settings.support.line.0=빌드 퍼니스에 공급 settings.support.line.1=파서 커피를 사세요 settings.support.line.2=유령이 나오는 작업 흐름을 덜 후원합니다. -workflow.run.jobs.title=워크플로 작업 workflow.run.jobs.root=워크플로 실행 workflow.run.jobs.description=GitHub 작업 작업 트리 및 선택한 작업 로그 workflow.run.tree.done=완료 diff --git a/src/main/resources/messages/GitHubWorkflowBundle_nl.properties b/src/main/resources/messages/GitHubWorkflowBundle_nl.properties index cf84812..ece386f 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_nl.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_nl.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub-workflow -plugin.description=Ondersteuning voor GitHub Actions-workflowbestanden group.GitHubWorkflow.Tools.text=GitHub-workflow group.GitHubWorkflow.Tools.description=GitHub Workflow-plug-intools -action.GitHubWorkflow.RefreshActionCache.text=Refresh actiecache -action.GitHubWorkflow.RefreshActionCache.description=Refresh heeft externe GitHub-acties en herbruikbare workflow-metagegevens opgelost +schema.auto={0} [Automatisch] +action.GitHubWorkflow.RefreshActionCache.text=Actiecache vernieuwen +action.GitHubWorkflow.RefreshActionCache.description=Vernieuw opgeloste externe GitHub Actions en herbruikbare workflowmetadata action.GitHubWorkflow.RestoreActionWarnings.text=Waarschuwingen voor herstelacties action.GitHubWorkflow.RestoreActionWarnings.description=Herstel onderdrukte actie-, invoer- en uitvoervalidatiewaarschuwingen action.GitHubWorkflow.ClearActionCache.text=Wis actiecache action.GitHubWorkflow.ClearActionCache.description=Wis in de cache opgeslagen GitHub-acties en herbruikbare workflow-metagegevens notification.cache.cleared={0}-gecachte GitHub-workflowgegevens gewist. -notification.cache.refresh.started=Refreshing {0} heeft externe GitHub-workflowgegevens in de cache opgeslagen. +notification.cache.refresh.started={0} externe GitHub Workflow-cachevermeldingen vernieuwen. notification.warnings.restored=Waarschuwingen voor {0} GitHub Workflow-items hersteld. workflow.run.configuration.display=GitHub-workflow workflow.run.configuration.description=Verzend en volg de workflow-uitvoeringen van GitHub-acties @@ -26,10 +25,11 @@ workflow.run.error.repository=Eigenaar en naam van de GitHub-repository zijn ver workflow.run.error.workflow=Workflowbestand is vereist. workflow.run.error.ref=Tak- of tagreferentie is vereist. workflow.run.error.inputs=GitHub workflow_dispatch ondersteunt maximaal 25 ingangen. +workflow.run.error.html=GitHub gaf een HTML-foutpagina terug in plaats van API-gegevens. workflow.run.gutter.stop=Stop de werkstroomuitvoering workflow.run.gutter.stop.text=Stop de werkstroomuitvoering workflow.run.gutter.stop.description=Annuleer deze uitvoering -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Instellingen > Versiebeheer > GitHub workflow.log.command=rennen: workflow.log.warning=waarschuwing: workflow.log.error=fout: @@ -113,6 +113,7 @@ documentation.servicePort.label=Servicepoort documentation.container.label=Baancontainer documentation.symbol.label=Workflow-symbool documentation.symbol.description=Opgeloste workflow-expressie. +documentation.workflowSyntax.label=Workflow-sleutel documentation.workflowOutput.label=Workflow-uitvoer documentation.jobOutput.label=Taakuitvoer documentation.action.label=Actie @@ -120,6 +121,9 @@ documentation.externalAction.label=Externe actie documentation.reusableWorkflow.label=Herbruikbare werkstroom documentation.resolvedFrom=opgelost vanuit {0} documentation.notResolved=nog niet opgelost +documentation.manualInput=Handmatig toegevoegde invoer +documentation.manualOutput=Handmatig toegevoegde uitvoer +documentation.provider=GitHub-workflowdocumentatie documentation.inputs.title=Ingangen documentation.outputs.title=Uitgangen documentation.secrets.title=Geheimen @@ -217,7 +221,6 @@ completion.uses.local.action=Lokale actie completion.uses.ref.known=Bekende workflowreferentie completion.uses.ref.remote=Referentie voor externe workflows completion.uses.remote.known=Bekende actie op afstand of herbruikbare workflow -completion.workflow.syntax=GitHub Syntaxis van de werkstroom voor acties completion.workflow.top.name=Weergavenaam van de werkstroom completion.workflow.top.run-name=Dynamische runnaam completion.workflow.top.on=Gebeurtenissen die de werkstroom starten @@ -387,7 +390,7 @@ settings.cache.state.resolved=opgelost settings.cache.state.pending=in afwachting settings.cache.state.expired=muf settings.cache.state.suppressed=onderdrukt -settings.cache.refresh=Refresh-tabel +settings.cache.refresh=Tabel vernieuwen settings.cache.deleteSelected=Geselecteerde verwijderen settings.cache.deleteAll=Alles verwijderen settings.cache.export=Exporteren @@ -402,12 +405,10 @@ settings.cache.import.done=Geïmporteerde cachegegevens. Het archiefbeest gedroe settings.cache.import.unsupported=Niet-ondersteund GitHub Workflow-cachebestand. settings.cache.import.brokenLine=Kapotte GitHub Workflow-cacheregel. settings.cache.import.brokenKey=Kapotte GitHub Workflow-cachesleutel. -settings.support.button=Ondersteun deze plug-in settings.support.tooltip=Open de ondersteuningspagina settings.support.line.0=Voed de bouwoven settings.support.line.1=Koop de parserkoffie settings.support.line.2=Sponsor minder spookachtige workflows -workflow.run.jobs.title=Workflow-taken workflow.run.jobs.root=Workflow uitgevoerd workflow.run.jobs.description=GitHub Acties taakboom en geselecteerd taaklogboek workflow.run.tree.done=gedaan diff --git a/src/main/resources/messages/GitHubWorkflowBundle_pl.properties b/src/main/resources/messages/GitHubWorkflowBundle_pl.properties index d3d27a4..a3fbd4d 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_pl.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_pl.properties @@ -1,15 +1,14 @@ -plugin.name=Przebieg pracy GitHub -plugin.description=Obsługa plików przepływu pracy akcji GitHub group.GitHubWorkflow.Tools.text=Przebieg pracy GitHub group.GitHubWorkflow.Tools.description=Narzędzia wtyczki GitHub Workflow -action.GitHubWorkflow.RefreshActionCache.text=Pamięć podręczna akcji Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh rozwiązał zdalne akcje GitHub i metadane przepływu pracy do ponownego wykorzystania +schema.auto={0} [Automatycznie] +action.GitHubWorkflow.RefreshActionCache.text=Odśwież pamięć podręczną akcji +action.GitHubWorkflow.RefreshActionCache.description=Odśwież rozwiązane zdalne akcje GitHub i metadane workflow do ponownego użycia action.GitHubWorkflow.RestoreActionWarnings.text=Przywróć ostrzeżenia dotyczące akcji action.GitHubWorkflow.RestoreActionWarnings.description=Przywróć pominięte działania, ostrzeżenia dotyczące sprawdzania danych wejściowych i wyjściowych action.GitHubWorkflow.ClearActionCache.text=Wyczyść pamięć podręczną akcji action.GitHubWorkflow.ClearActionCache.description=Wyczyść buforowane akcje GitHub i metadane przepływu pracy do ponownego wykorzystania notification.cache.cleared=Wyczyszczono wpisy przepływu pracy {0} w pamięci podręcznej GitHub. -notification.cache.refresh.started=Refreshing {0} buforowane zdalne wpisy przepływu pracy GitHub. +notification.cache.refresh.started=Odświeżanie {0} zdalnych wpisów GitHub Workflow w pamięci podręcznej. notification.warnings.restored=Przywrócono ostrzeżenia dla wpisów przepływu pracy {0} GitHub. workflow.run.configuration.display=Przebieg pracy GitHub workflow.run.configuration.description=Wysyłaj i śledź przebiegi przepływu pracy akcji GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=Wymagany jest właściciel i nazwa repozytorium Gi workflow.run.error.workflow=Wymagany jest plik przepływu pracy. workflow.run.error.ref=Wymagany jest numer oddziału lub tagu. workflow.run.error.inputs=GitHub workflow_dispatch obsługuje maksymalnie 25 wejść. +workflow.run.error.html=GitHub zwrócił stronę błędu HTML zamiast danych API. workflow.run.gutter.stop=Zatrzymaj przebieg przepływu pracy workflow.run.gutter.stop.text=Zatrzymaj przebieg przepływu pracy workflow.run.gutter.stop.description=Anuluj ten bieg -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Ustawienia > Kontrola wersji > GitHub workflow.log.command=biegnij: workflow.log.warning=ostrzeżenie: workflow.log.error=błąd: @@ -113,6 +113,7 @@ documentation.servicePort.label=Port serwisowy documentation.container.label=Kontener pracy documentation.symbol.label=Symbol przepływu pracy documentation.symbol.description=Rozwiązane wyrażenie przepływu pracy. +documentation.workflowSyntax.label=Klucz workflow documentation.workflowOutput.label=Dane wyjściowe przepływu pracy documentation.jobOutput.label=Dane wyjściowe zadania documentation.action.label=Akcja @@ -120,6 +121,9 @@ documentation.externalAction.label=Działania zewnętrzne documentation.reusableWorkflow.label=Przepływ pracy wielokrotnego użytku documentation.resolvedFrom=rozwiązany z {0} documentation.notResolved=jeszcze nie rozwiązany +documentation.manualInput=Ręcznie dodane wejście +documentation.manualOutput=Ręcznie dodane wyjście +documentation.provider=Dokumentacja workflow GitHub documentation.inputs.title=Wejścia documentation.outputs.title=Wyjścia documentation.secrets.title=Sekrety @@ -217,7 +221,6 @@ completion.uses.local.action=Akcja lokalna completion.uses.ref.known=Znane odniesienie do przepływu pracy completion.uses.ref.remote=Odniesienie do zdalnego przepływu pracy completion.uses.remote.known=Znane zdalne działanie lub przepływ pracy do ponownego wykorzystania -completion.workflow.syntax=GitHub Składnia przepływu pracy działań completion.workflow.top.name=Nazwa wyświetlana przepływu pracy completion.workflow.top.run-name=Dynamiczna nazwa uruchomienia completion.workflow.top.on=Zdarzenia rozpoczynające przepływ pracy @@ -387,7 +390,7 @@ settings.cache.state.resolved=rozwiązany settings.cache.state.pending=w toku settings.cache.state.expired=nieaktualne settings.cache.state.suppressed=stłumiony -settings.cache.refresh=Tabela Refresh +settings.cache.refresh=Odśwież tabelę settings.cache.deleteSelected=Usuń wybrane settings.cache.deleteAll=Usuń wszystko settings.cache.export=Eksportuj @@ -402,12 +405,10 @@ settings.cache.import.done=Zaimportowane wpisy pamięci podręcznej. Bestia z ar settings.cache.import.unsupported=Nieobsługiwany plik pamięci podręcznej przepływu pracy GitHub. settings.cache.import.brokenLine=Uszkodzona linia pamięci podręcznej przepływu pracy GitHub. settings.cache.import.brokenKey=Uszkodzony klucz pamięci podręcznej GitHub przepływu pracy. -settings.support.button=Wesprzyj tę wtyczkę settings.support.tooltip=Otwórz stronę wsparcia settings.support.line.0=Zasil piec budowlany settings.support.line.1=Kup kawę parserową settings.support.line.2=Sponsoruj mniej nawiedzonych przepływów pracy -workflow.run.jobs.title=Zadania przepływu pracy workflow.run.jobs.root=Uruchomienie przepływu pracy workflow.run.jobs.description=GitHub Drzewo zadań akcji i wybrany protokół zadania workflow.run.tree.done=zrobione diff --git a/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties b/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties index 1bcae49..deaffc5 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties @@ -1,15 +1,14 @@ -plugin.name=Fluxo de trabalho GitHub -plugin.description=Suporte para arquivos de fluxo de trabalho de ações GitHub group.GitHubWorkflow.Tools.text=Fluxo de trabalho GitHub group.GitHubWorkflow.Tools.description=Ferramentas de plug-in de fluxo de trabalho GitHub -action.GitHubWorkflow.RefreshActionCache.text=Cache de ação Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh resolveu ações GitHub remotas e metadados de fluxo de trabalho reutilizáveis +schema.auto={0} [Automático] +action.GitHubWorkflow.RefreshActionCache.text=Atualizar cache de ações +action.GitHubWorkflow.RefreshActionCache.description=Atualizar metadados de ações GitHub remotas resolvidas e workflows reutilizáveis action.GitHubWorkflow.RestoreActionWarnings.text=Avisos de ação de restauração action.GitHubWorkflow.RestoreActionWarnings.description=Restaurar avisos de validação de ação, entrada e saída suprimidos action.GitHubWorkflow.ClearActionCache.text=Limpar cache de ações action.GitHubWorkflow.ClearActionCache.description=Limpar ações GitHub em cache e metadados de fluxo de trabalho reutilizáveis notification.cache.cleared=Entradas de fluxo de trabalho GitHub armazenadas em cache do {0} foram limpas. -notification.cache.refresh.started=Refreshing {0} armazenou em cache entradas remotas do fluxo de trabalho GitHub. +notification.cache.refresh.started=Atualizando {0} entradas remotas do GitHub Workflow em cache. notification.warnings.restored=Avisos restaurados para entradas do fluxo de trabalho {0} GitHub. workflow.run.configuration.display=Fluxo de trabalho GitHub workflow.run.configuration.description=Despachar e seguir execuções de fluxo de trabalho de ações GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=O proprietário e o nome do repositório GitHub s workflow.run.error.workflow=O arquivo de fluxo de trabalho é obrigatório. workflow.run.error.ref=A referência de ramificação ou tag é obrigatória. workflow.run.error.inputs=GitHub workflow_dispatch suporta no máximo 25 entradas. +workflow.run.error.html=GitHub retornou uma página de erro HTML em vez de dados da API. workflow.run.gutter.stop=Interromper a execução do fluxo de trabalho workflow.run.gutter.stop.text=Interromper a execução do fluxo de trabalho workflow.run.gutter.stop.description=Cancelar esta execução -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Configurações > Controle de versão > GitHub workflow.log.command=execute: workflow.log.warning=aviso: workflow.log.error=erro: @@ -113,6 +113,7 @@ documentation.servicePort.label=Porta de serviço documentation.container.label=Contêiner de trabalho documentation.symbol.label=Símbolo de fluxo de trabalho documentation.symbol.description=Expressão de fluxo de trabalho resolvida. +documentation.workflowSyntax.label=Chave do workflow documentation.workflowOutput.label=Saída do fluxo de trabalho documentation.jobOutput.label=Saída do trabalho documentation.action.label=Ação @@ -120,6 +121,9 @@ documentation.externalAction.label=Ação externa documentation.reusableWorkflow.label=Fluxo de trabalho reutilizável documentation.resolvedFrom=resolvido de {0} documentation.notResolved=ainda não resolvido +documentation.manualInput=Entrada adicionada manualmente +documentation.manualOutput=Saída adicionada manualmente +documentation.provider=Documentação do fluxo de trabalho GitHub documentation.inputs.title=Entradas documentation.outputs.title=Resultados documentation.secrets.title=Segredos @@ -217,7 +221,6 @@ completion.uses.local.action=Ação local completion.uses.ref.known=Referência de fluxo de trabalho conhecida completion.uses.ref.remote=Referência de fluxo de trabalho remoto completion.uses.remote.known=Ação remota conhecida ou fluxo de trabalho reutilizável -completion.workflow.syntax=Sintaxe do fluxo de trabalho de ações GitHub completion.workflow.top.name=Nome de exibição do fluxo de trabalho completion.workflow.top.run-name=Nome da execução dinâmica completion.workflow.top.on=Eventos que iniciam o fluxo de trabalho @@ -387,7 +390,7 @@ settings.cache.state.resolved=resolvido settings.cache.state.pending=pendente settings.cache.state.expired=obsoleto settings.cache.state.suppressed=suprimido -settings.cache.refresh=Tabela Refresh +settings.cache.refresh=Atualizar tabela settings.cache.deleteSelected=Excluir selecionado settings.cache.deleteAll=Excluir tudo settings.cache.export=Exportar @@ -402,12 +405,10 @@ settings.cache.import.done=Entradas de cache importadas. A fera do arquivo se co settings.cache.import.unsupported=Arquivo de cache do fluxo de trabalho GitHub não suportado. settings.cache.import.brokenLine=Linha de cache do fluxo de trabalho GitHub quebrada. settings.cache.import.brokenKey=Chave de cache do fluxo de trabalho GitHub quebrada. -settings.support.button=Apoie este plugin settings.support.tooltip=Abra a página de suporte settings.support.line.0=Alimente o forno de construção settings.support.line.1=Compre o café analisador settings.support.line.2=Patrocine menos fluxos de trabalho assombrados -workflow.run.jobs.title=Trabalhos de fluxo de trabalho workflow.run.jobs.root=Execução do fluxo de trabalho workflow.run.jobs.description=Árvore de tarefas de ações GitHub e log de tarefas selecionado workflow.run.tree.done=feito diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ru.properties b/src/main/resources/messages/GitHubWorkflowBundle_ru.properties index 4a4ca1d..8e7eb95 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ru.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ru.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub Рабочий процесс -plugin.description=Поддержка файлов рабочего процесса действий GitHub. group.GitHubWorkflow.Tools.text=GitHub Рабочий процесс group.GitHubWorkflow.Tools.description=GitHub Инструменты плагина рабочего процесса -action.GitHubWorkflow.RefreshActionCache.text=Кэш действий Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh разрешил удаленные действия GitHub и повторно используемые метаданные рабочего процесса. +schema.auto={0} [Авто] +action.GitHubWorkflow.RefreshActionCache.text=Обновить кэш действий +action.GitHubWorkflow.RefreshActionCache.description=Обновить метаданные разрешенных удаленных действий GitHub и повторно используемых workflow action.GitHubWorkflow.RestoreActionWarnings.text=Предупреждения о действиях по восстановлению action.GitHubWorkflow.RestoreActionWarnings.description=Восстановление подавленных действий, предупреждений проверки ввода и вывода. action.GitHubWorkflow.ClearActionCache.text=Очистить кэш действий action.GitHubWorkflow.ClearActionCache.description=Очистка кэшированных действий GitHub и повторно используемых метаданных рабочего процесса. notification.cache.cleared=Очищены кэшированные записи рабочего процесса {0} GitHub. -notification.cache.refresh.started=Refreshing {0} кэшировал удаленные записи рабочего процесса GitHub. +notification.cache.refresh.started=Обновление {0} кэшированных удаленных записей GitHub Workflow. notification.warnings.restored=Восстановлены предупреждения для записей рабочего процесса {0} GitHub. workflow.run.configuration.display=GitHub Рабочий процесс workflow.run.configuration.description=Отправка и отслеживание выполнения рабочего процесса действий GitHub. @@ -26,10 +25,11 @@ workflow.run.error.repository=Требуется владелец и имя ре workflow.run.error.workflow=Требуется файл рабочего процесса. workflow.run.error.ref=Требуется ссылка на ветку или тег. workflow.run.error.inputs=GitHub workflow_dispatch поддерживает максимум 25 входов. +workflow.run.error.html=GitHub вернул HTML-страницу ошибки вместо данных API. workflow.run.gutter.stop=Остановить выполнение рабочего процесса workflow.run.gutter.stop.text=Остановить выполнение рабочего процесса workflow.run.gutter.stop.description=Отменить этот запуск -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Настройки > Контроль версий > GitHub workflow.log.command=запустить: workflow.log.warning=предупреждение: workflow.log.error=ошибка: @@ -113,6 +113,7 @@ documentation.servicePort.label=Сервисный порт documentation.container.label=Контейнер заданий documentation.symbol.label=Символ рабочего процесса documentation.symbol.description=Разрешено выражение рабочего процесса. +documentation.workflowSyntax.label=Ключ workflow documentation.workflowOutput.label=Выходные данные рабочего процесса documentation.jobOutput.label=Вывод задания documentation.action.label=Действие @@ -120,6 +121,9 @@ documentation.externalAction.label=Внешнее действие documentation.reusableWorkflow.label=Многоразовый рабочий процесс documentation.resolvedFrom=решено из {0} documentation.notResolved=еще не решено +documentation.manualInput=Вход добавлен вручную +documentation.manualOutput=Выход добавлен вручную +documentation.provider=Документация workflow GitHub documentation.inputs.title=Входы documentation.outputs.title=Выходы documentation.secrets.title=Секреты @@ -217,7 +221,6 @@ completion.uses.local.action=Местное действие completion.uses.ref.known=Известная ссылка на рабочий процесс completion.uses.ref.remote=Справочник по удаленному рабочему процессу completion.uses.remote.known=Известное удаленное действие или многократно используемый рабочий процесс -completion.workflow.syntax=GitHub Синтаксис рабочего процесса действий completion.workflow.top.name=Отображаемое имя рабочего процесса completion.workflow.top.run-name=Динамическое имя запуска completion.workflow.top.on=События, запускающие рабочий процесс @@ -387,7 +390,7 @@ settings.cache.state.resolved=решено settings.cache.state.pending=в ожидании settings.cache.state.expired=несвежий settings.cache.state.suppressed=подавленный -settings.cache.refresh=Таблица Refresh +settings.cache.refresh=Обновить таблицу settings.cache.deleteSelected=Удалить выбранное settings.cache.deleteAll=Удалить все settings.cache.export=Экспорт @@ -402,12 +405,10 @@ settings.cache.import.done=Импортированные записи кэша. settings.cache.import.unsupported=Неподдерживаемый файл кэша рабочего процесса GitHub. settings.cache.import.brokenLine=Неработающая строка кэша рабочего процесса GitHub. settings.cache.import.brokenKey=Неработающий ключ кэша рабочего процесса GitHub. -settings.support.button=Поддержите этот плагин settings.support.tooltip=Откройте страницу поддержки settings.support.line.0=Подача строительной печи settings.support.line.1=Купить парсер кофе settings.support.line.2=Спонсируйте меньше запутанных рабочих процессов -workflow.run.jobs.title=Задания рабочего процесса workflow.run.jobs.root=Запуск рабочего процесса workflow.run.jobs.description=GitHub Дерево заданий «Действия» и выбранный журнал заданий workflow.run.tree.done=сделано diff --git a/src/main/resources/messages/GitHubWorkflowBundle_sv.properties b/src/main/resources/messages/GitHubWorkflowBundle_sv.properties index 8949d30..b78045b 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_sv.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_sv.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub arbetsflöde -plugin.description=Stöd för GitHub Actions arbetsflödesfiler group.GitHubWorkflow.Tools.text=GitHub arbetsflöde group.GitHubWorkflow.Tools.description=GitHub Workflow plugin-verktyg +schema.auto={0} [Automatiskt] action.GitHubWorkflow.RefreshActionCache.text=Uppdatera åtgärdscache -action.GitHubWorkflow.RefreshActionCache.description=Refresh löste fjärr GitHub-åtgärder och återanvändbar arbetsflödesmetadata +action.GitHubWorkflow.RefreshActionCache.description=Uppdatera metadata för lösta fjärr-GitHub Actions och återanvändbara arbetsflöden action.GitHubWorkflow.RestoreActionWarnings.text=Återställ åtgärdsvarningar action.GitHubWorkflow.RestoreActionWarnings.description=Återställ varningar för undertryckta åtgärder, inmatning och utdatavalidering action.GitHubWorkflow.ClearActionCache.text=Rensa Action Cache action.GitHubWorkflow.ClearActionCache.description=Rensa cachade GitHub-åtgärder och återanvändbar arbetsflödesmetadata notification.cache.cleared=Rensade {0} cachade GitHub arbetsflödesposter. -notification.cache.refresh.started=Refreshing {0} cachade fjärr GitHub arbetsflödesposter. +notification.cache.refresh.started=Uppdaterar {0} cachade fjärrposter för GitHub Workflow. notification.warnings.restored=Återställda varningar för {0} GitHub Workflow-poster. workflow.run.configuration.display=GitHub arbetsflöde workflow.run.configuration.description=Skicka och följ GitHub Actions-arbetsflödeskörningar @@ -26,10 +25,11 @@ workflow.run.error.repository=GitHub-förvarets ägare och namn krävs. workflow.run.error.workflow=Arbetsflödesfil krävs. workflow.run.error.ref=Filial eller tagreferens krävs. workflow.run.error.inputs=GitHub workflow_dispatch stöder högst 25 ingångar. +workflow.run.error.html=GitHub returnerade en HTML-felsida i stället för API-data. workflow.run.gutter.stop=Stoppa körningen av arbetsflödet workflow.run.gutter.stop.text=Stoppa körningen av arbetsflödet workflow.run.gutter.stop.description=Avbryt denna körning -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Inställningar > Versionskontroll > GitHub workflow.log.command=kör: workflow.log.warning=varning: workflow.log.error=fel: @@ -113,6 +113,7 @@ documentation.servicePort.label=Serviceport documentation.container.label=Jobbcontainer documentation.symbol.label=Arbetsflödessymbol documentation.symbol.description=Löst arbetsflödesuttryck. +documentation.workflowSyntax.label=Arbetsflödesnyckel documentation.workflowOutput.label=Arbetsflödesutgång documentation.jobOutput.label=Jobbutgång documentation.action.label=Åtgärd @@ -120,6 +121,9 @@ documentation.externalAction.label=Extern åtgärd documentation.reusableWorkflow.label=Återanvändbart arbetsflöde documentation.resolvedFrom=löst från {0} documentation.notResolved=inte löst ännu +documentation.manualInput=Manuellt tillagd indata +documentation.manualOutput=Manuellt tillagd utdata +documentation.provider=GitHub-arbetsflödesdokumentation documentation.inputs.title=Ingångar documentation.outputs.title=Utgångar documentation.secrets.title=Hemligheter @@ -217,7 +221,6 @@ completion.uses.local.action=Lokal handling completion.uses.ref.known=Känd arbetsflödesreferens completion.uses.ref.remote=Referens för fjärrarbetsflöde completion.uses.remote.known=Känd fjärråtgärd eller återanvändbart arbetsflöde -completion.workflow.syntax=GitHub Actions arbetsflödessyntax completion.workflow.top.name=Visningsnamn för arbetsflöde completion.workflow.top.run-name=Dynamiskt körnamn completion.workflow.top.on=Händelser som startar arbetsflödet @@ -387,7 +390,7 @@ settings.cache.state.resolved=löst settings.cache.state.pending=väntande settings.cache.state.expired=inaktuella settings.cache.state.suppressed=undertryckt -settings.cache.refresh=Refresh tabell +settings.cache.refresh=Uppdatera tabellen settings.cache.deleteSelected=Ta bort markerade settings.cache.deleteAll=Ta bort alla settings.cache.export=Exportera @@ -402,12 +405,10 @@ settings.cache.import.done=Importerade cacheposter. Arkivodjuret betedde sig. settings.cache.import.unsupported=GitHub Workflow-cachefil som inte stöds. settings.cache.import.brokenLine=Trasig GitHub Workflow cache-linje. settings.cache.import.brokenKey=Trasig GitHub Workflow cache-nyckel. -settings.support.button=Stöd detta plugin settings.support.tooltip=Öppna supportsidan settings.support.line.0=Mata byggugnen settings.support.line.1=Köp parserkaffe settings.support.line.2=Sponsra färre hemsökta arbetsflöden -workflow.run.jobs.title=Arbetsflödesjobb workflow.run.jobs.root=Arbetsflödeskörning workflow.run.jobs.description=GitHub Actions jobbträd och vald jobblogg workflow.run.tree.done=gjort diff --git a/src/main/resources/messages/GitHubWorkflowBundle_th.properties b/src/main/resources/messages/GitHubWorkflowBundle_th.properties index 73c798e..f55aed7 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_th.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_th.properties @@ -1,15 +1,14 @@ -plugin.name=เวิร์กโฟลว์ GitHub -plugin.description=รองรับไฟล์เวิร์กโฟลว์ GitHub Actions group.GitHubWorkflow.Tools.text=เวิร์กโฟลว์ GitHub group.GitHubWorkflow.Tools.description=เครื่องมือปลั๊กอิน GitHub Workflow -action.GitHubWorkflow.RefreshActionCache.text=แคชการดำเนินการ Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh แก้ไขการดำเนินการ GitHub ระยะไกลและข้อมูลเมตาเวิร์กโฟลว์ที่นำมาใช้ซ้ำได้ +schema.auto={0} [อัตโนมัติ] +action.GitHubWorkflow.RefreshActionCache.text=รีเฟรชแคชแอ็กชัน +action.GitHubWorkflow.RefreshActionCache.description=รีเฟรชเมตาดาตาของ GitHub Actions ระยะไกลที่แก้ไขแล้วและเวิร์กโฟลว์ที่ใช้ซ้ำได้ action.GitHubWorkflow.RestoreActionWarnings.text=คืนค่าคำเตือนการดำเนินการ action.GitHubWorkflow.RestoreActionWarnings.description=คืนค่าคำเตือนการตรวจสอบความถูกต้องของการดำเนินการ อินพุต และเอาต์พุตที่ถูกระงับ action.GitHubWorkflow.ClearActionCache.text=ล้างแคชการดำเนินการ action.GitHubWorkflow.ClearActionCache.description=ล้างแคชการดำเนินการ GitHub และข้อมูลเมตาเวิร์กโฟลว์ที่นำมาใช้ซ้ำได้ notification.cache.cleared=ล้างรายการเวิร์กโฟลว์ {0} ที่แคชไว้ GitHub -notification.cache.refresh.started=Refreshing {0} แคชรายการเวิร์กโฟลว์ GitHub ระยะไกล +notification.cache.refresh.started=กำลังรีเฟรชรายการ GitHub Workflow ระยะไกลที่แคชไว้ {0} รายการ notification.warnings.restored=กู้คืนคำเตือนสำหรับรายการเวิร์กโฟลว์ {0} GitHub workflow.run.configuration.display=เวิร์กโฟลว์ GitHub workflow.run.configuration.description=จัดส่งและติดตามเวิร์กโฟลว์การดำเนินการ GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=ต้องระบุเจ้าของท workflow.run.error.workflow=จำเป็นต้องมีไฟล์เวิร์กโฟลว์ workflow.run.error.ref=จำเป็นต้องมีการอ้างอิงสาขาหรือแท็ก workflow.run.error.inputs=GitHub workflow_dispatch รองรับอินพุตได้สูงสุด 25 อินพุต +workflow.run.error.html=GitHub ส่งคืนหน้าแสดงข้อผิดพลาด HTML แทนข้อมูล API workflow.run.gutter.stop=หยุดการรันเวิร์กโฟลว์ workflow.run.gutter.stop.text=หยุดการรันเวิร์กโฟลว์ workflow.run.gutter.stop.description=ยกเลิกการวิ่งครั้งนี้ -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=การตั้งค่า > การควบคุมเวอร์ชัน > GitHub workflow.log.command=วิ่ง: workflow.log.warning=คำเตือน: workflow.log.error=ข้อผิดพลาด: @@ -113,6 +113,7 @@ documentation.servicePort.label=พอร์ตบริการ documentation.container.label=ตู้ใส่งาน documentation.symbol.label=สัญลักษณ์เวิร์กโฟลว์ documentation.symbol.description=นิพจน์เวิร์กโฟลว์ได้รับการแก้ไขแล้ว +documentation.workflowSyntax.label=คีย์เวิร์กโฟลว์ documentation.workflowOutput.label=เอาท์พุตเวิร์กโฟลว์ documentation.jobOutput.label=ผลผลิตงาน documentation.action.label=การดำเนินการ @@ -120,6 +121,9 @@ documentation.externalAction.label=การกระทำภายนอก documentation.reusableWorkflow.label=ขั้นตอนการทำงานแบบใช้ซ้ำได้ documentation.resolvedFrom=แก้ไขจาก {0} documentation.notResolved=ยังไม่ได้รับการแก้ไข +documentation.manualInput=อินพุตที่เพิ่มด้วยตนเอง +documentation.manualOutput=เอาต์พุตที่เพิ่มด้วยตนเอง +documentation.provider=เอกสารเวิร์กโฟลว์ GitHub documentation.inputs.title=อินพุต documentation.outputs.title=เอาท์พุต documentation.secrets.title=ความลับ @@ -217,7 +221,6 @@ completion.uses.local.action=การกระทำในท้องถิ่ completion.uses.ref.known=การอ้างอิงเวิร์กโฟลว์ที่รู้จัก completion.uses.ref.remote=การอ้างอิงเวิร์กโฟลว์ระยะไกล completion.uses.remote.known=การดำเนินการระยะไกลที่รู้จักหรือเวิร์กโฟลว์ที่นำมาใช้ซ้ำได้ -completion.workflow.syntax=ไวยากรณ์เวิร์กโฟลว์การดำเนินการ GitHub completion.workflow.top.name=ชื่อที่แสดงเวิร์กโฟลว์ completion.workflow.top.run-name=ชื่อการรันแบบไดนามิก completion.workflow.top.on=เหตุการณ์ที่เริ่มต้นเวิร์กโฟลว์ @@ -387,7 +390,7 @@ settings.cache.state.resolved=แก้ไขแล้ว settings.cache.state.pending=รอดำเนินการ settings.cache.state.expired=เหม็นอับ settings.cache.state.suppressed=ระงับ -settings.cache.refresh=ตาราง Refresh +settings.cache.refresh=รีเฟรชตาราง settings.cache.deleteSelected=ลบที่เลือกไว้ settings.cache.deleteAll=ลบทั้งหมด settings.cache.export=ส่งออก @@ -402,12 +405,10 @@ settings.cache.import.done=รายการแคชที่นำเข้ settings.cache.import.unsupported=ไฟล์แคชเวิร์กโฟลว์ GitHub ที่ไม่รองรับ settings.cache.import.brokenLine=บรรทัดแคชเวิร์กโฟลว์ GitHub ใช้งานไม่ได้ settings.cache.import.brokenKey=คีย์แคชเวิร์กโฟลว์ GitHub ใช้งานไม่ได้ -settings.support.button=สนับสนุนปลั๊กอินนี้ settings.support.tooltip=เปิดหน้าสนับสนุน settings.support.line.0=ป้อนเตาหลอม settings.support.line.1=ซื้อกาแฟพาร์เซอร์ settings.support.line.2=สนับสนุนเวิร์กโฟลว์ที่มีผีสิงน้อยลง -workflow.run.jobs.title=งานเวิร์กโฟลว์ workflow.run.jobs.root=เวิร์กโฟลว์ทำงาน workflow.run.jobs.description=แผนผังงานการดำเนินการ GitHub และบันทึกงานที่เลือก workflow.run.tree.done=เสร็จแล้ว diff --git a/src/main/resources/messages/GitHubWorkflowBundle_tr.properties b/src/main/resources/messages/GitHubWorkflowBundle_tr.properties index 2807735..82f7b9a 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_tr.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_tr.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub İş Akışı -plugin.description=GitHub Eylemleri iş akışı dosyaları desteği group.GitHubWorkflow.Tools.text=GitHub İş Akışı group.GitHubWorkflow.Tools.description=GitHub İş Akışı eklenti araçları -action.GitHubWorkflow.RefreshActionCache.text=Refresh Eylem Önbelleği -action.GitHubWorkflow.RefreshActionCache.description=Refresh, uzak GitHub Eylemlerini ve yeniden kullanılabilir iş akışı meta verilerini çözdü +schema.auto={0} [Otomatik] +action.GitHubWorkflow.RefreshActionCache.text=Eylem önbelleğini yenile +action.GitHubWorkflow.RefreshActionCache.description=Çözülen uzak GitHub Actions ve yeniden kullanılabilir workflow meta verilerini yenile action.GitHubWorkflow.RestoreActionWarnings.text=Eylem Uyarılarını Geri Yükle action.GitHubWorkflow.RestoreActionWarnings.description=Bastırılmış eylem, giriş ve çıkış doğrulama uyarılarını geri yükleyin action.GitHubWorkflow.ClearActionCache.text=Eylem Önbelleğini Temizle action.GitHubWorkflow.ClearActionCache.description=Önbelleğe alınmış GitHub Eylemlerini ve yeniden kullanılabilir iş akışı meta verilerini temizleyin notification.cache.cleared={0} önbelleğe alınmış GitHub İş Akışı girişleri temizlendi. -notification.cache.refresh.started=Refreshing {0} uzak GitHub İş Akışı girişlerini önbelleğe aldı. +notification.cache.refresh.started=Önbellekteki {0} uzak GitHub Workflow girdisi yenileniyor. notification.warnings.restored={0} GitHub İş Akışı girişleri için uyarılar geri yüklendi. workflow.run.configuration.display=GitHub İş Akışı workflow.run.configuration.description=GitHub Eylemleri iş akışı çalıştırmalarını gönderin ve takip edin @@ -26,10 +25,11 @@ workflow.run.error.repository=GitHub deposu sahibi ve adı gereklidir. workflow.run.error.workflow=İş akışı dosyası gerekli. workflow.run.error.ref=Şube veya etiket referansı gerekli. workflow.run.error.inputs=GitHub workflow_dispatch en fazla 25 girişi destekler. +workflow.run.error.html=GitHub, API verileri yerine HTML hata sayfası döndürdü. workflow.run.gutter.stop=İş akışı çalışmasını durdur workflow.run.gutter.stop.text=İş akışı çalışmasını durdur workflow.run.gutter.stop.description=Bu çalıştırmayı iptal et -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Ayarlar > Sürüm Denetimi > GitHub workflow.log.command=koş: workflow.log.warning=uyarı: workflow.log.error=hata: @@ -113,6 +113,7 @@ documentation.servicePort.label=Servis portu documentation.container.label=İş kapsayıcısı documentation.symbol.label=İş akışı sembolü documentation.symbol.description=Çözümlenen iş akışı ifadesi. +documentation.workflowSyntax.label=İş akışı anahtarı documentation.workflowOutput.label=İş akışı çıkışı documentation.jobOutput.label=İş çıkışı documentation.action.label=Eylem @@ -120,6 +121,9 @@ documentation.externalAction.label=Harici eylem documentation.reusableWorkflow.label=Yeniden kullanılabilir iş akışı documentation.resolvedFrom={0}''ten çözüldü documentation.notResolved=henüz çözülmedi +documentation.manualInput=Elle eklenen giriş +documentation.manualOutput=Elle eklenen çıkış +documentation.provider=GitHub iş akışı belgeleri documentation.inputs.title=Girişler documentation.outputs.title=Çıkışlar documentation.secrets.title=Sırlar @@ -217,7 +221,6 @@ completion.uses.local.action=Yerel eylem completion.uses.ref.known=Bilinen iş akışı referansı completion.uses.ref.remote=Uzaktan iş akışı referansı completion.uses.remote.known=Bilinen uzaktan işlem veya yeniden kullanılabilir iş akışı -completion.workflow.syntax=GitHub Eylemler iş akışı sözdizimi completion.workflow.top.name=İş akışı görünen adı completion.workflow.top.run-name=Dinamik çalıştırma adı completion.workflow.top.on=İş akışını başlatan olaylar @@ -387,7 +390,7 @@ settings.cache.state.resolved=çözüldü settings.cache.state.pending=beklemede settings.cache.state.expired=bayat settings.cache.state.suppressed=bastırılmış -settings.cache.refresh=Refresh tablosu +settings.cache.refresh=Tabloyu yenile settings.cache.deleteSelected=Seçileni sil settings.cache.deleteAll=Tümünü sil settings.cache.export=İhracat @@ -402,12 +405,10 @@ settings.cache.import.done=İçe aktarılan önbellek girişleri. Arşiv canavar settings.cache.import.unsupported=Desteklenmeyen GitHub İş Akışı önbellek dosyası. settings.cache.import.brokenLine=Bozuk GitHub İş Akışı önbellek hattı. settings.cache.import.brokenKey=Bozuk GitHub İş Akışı önbellek anahtarı. -settings.support.button=Bu eklentiyi destekleyin settings.support.tooltip=Destek sayfasını açın settings.support.line.0=Yapı fırınını besleyin settings.support.line.1=Ayrıştırıcı kahveyi satın alın settings.support.line.2=Daha az rahatsız edici iş akışına sponsor olun -workflow.run.jobs.title=İş akışı işleri workflow.run.jobs.root=İş akışı çalıştırması workflow.run.jobs.description=GitHub Eylemler iş ağacı ve seçilen iş günlüğü workflow.run.tree.done=bitti diff --git a/src/main/resources/messages/GitHubWorkflowBundle_uk.properties b/src/main/resources/messages/GitHubWorkflowBundle_uk.properties index 552f490..0587461 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_uk.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_uk.properties @@ -1,15 +1,14 @@ -plugin.name=Робочий процес GitHub -plugin.description=Підтримка файлів робочого циклу дій GitHub group.GitHubWorkflow.Tools.text=Робочий процес GitHub group.GitHubWorkflow.Tools.description=GitHub Плагіни робочого процесу -action.GitHubWorkflow.RefreshActionCache.text=Кеш дій Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh вирішив віддалені дії GitHub і повторно використовувані метадані робочого процесу +schema.auto={0} [Авто] +action.GitHubWorkflow.RefreshActionCache.text=Оновити кеш дій +action.GitHubWorkflow.RefreshActionCache.description=Оновити метадані вирішених віддалених дій GitHub і повторно використовуваних workflow action.GitHubWorkflow.RestoreActionWarnings.text=Попередження про дії відновлення action.GitHubWorkflow.RestoreActionWarnings.description=Відновлення пригнічених дій, попереджень перевірки введення та виведення action.GitHubWorkflow.ClearActionCache.text=Очистити кеш дій action.GitHubWorkflow.ClearActionCache.description=Очистити кешовані дії GitHub і повторно використовувані метадані робочого процесу notification.cache.cleared=Видалено {0} кешовані записи робочого циклу GitHub. -notification.cache.refresh.started=Refоновлення {0} кешованих записів віддаленого робочого процесу GitHub. +notification.cache.refresh.started=Оновлення {0} кешованих віддалених записів GitHub Workflow. notification.warnings.restored=Відновлено попередження для записів робочого процесу {0} GitHub. workflow.run.configuration.display=Робочий процес GitHub workflow.run.configuration.description=Відправлення та виконання робочого циклу дій GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=Потрібні власник і ім’я сх workflow.run.error.workflow=Потрібен файл робочого процесу. workflow.run.error.ref=Потрібно вказати посилання на гілку або тег. workflow.run.error.inputs=GitHub workflow_dispatch підтримує щонайбільше 25 входів. +workflow.run.error.html=GitHub повернув HTML-сторінку помилки замість даних API. workflow.run.gutter.stop=Зупинити запуск робочого процесу workflow.run.gutter.stop.text=Зупинити запуск робочого процесу workflow.run.gutter.stop.description=Скасувати цей запуск -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=Налаштування > Контроль версій > GitHub workflow.log.command=запустити: workflow.log.warning=попередження: workflow.log.error=помилка: @@ -113,6 +113,7 @@ documentation.servicePort.label=Службовий порт documentation.container.label=Контейнер для роботи documentation.symbol.label=Символ робочого процесу documentation.symbol.description=Вирішений вираз робочого процесу. +documentation.workflowSyntax.label=Ключ workflow documentation.workflowOutput.label=Вивід робочого процесу documentation.jobOutput.label=Вихід роботи documentation.action.label=Дія @@ -120,6 +121,9 @@ documentation.externalAction.label=Зовнішня дія documentation.reusableWorkflow.label=Багаторазовий робочий процес documentation.resolvedFrom=вирішено з {0} documentation.notResolved=ще не вирішено +documentation.manualInput=Вхід додано вручну +documentation.manualOutput=Вихід додано вручну +documentation.provider=Документація workflow GitHub documentation.inputs.title=Вхідні дані documentation.outputs.title=Виходи documentation.secrets.title=Секрети @@ -217,7 +221,6 @@ completion.uses.local.action=Місцева дія completion.uses.ref.known=Відомий робочий процес completion.uses.ref.remote=Довідка про віддалений робочий процес completion.uses.remote.known=Відома віддалена дія або багаторазовий робочий процес -completion.workflow.syntax=GitHub Синтаксис робочого циклу дій completion.workflow.top.name=Відображувана назва робочого процесу completion.workflow.top.run-name=Динамічна назва запуску completion.workflow.top.on=Події, які починають робочий процес @@ -387,7 +390,7 @@ settings.cache.state.resolved=вирішено settings.cache.state.pending=в очікуванні settings.cache.state.expired=несвіжий settings.cache.state.suppressed=пригнічений -settings.cache.refresh=Таблиця Refresh +settings.cache.refresh=Оновити таблицю settings.cache.deleteSelected=Видалити вибране settings.cache.deleteAll=Видалити все settings.cache.export=Експорт @@ -402,12 +405,10 @@ settings.cache.import.done=Імпортовані записи кешу. Арх settings.cache.import.unsupported=Непідтримуваний файл кешу робочого процесу GitHub. settings.cache.import.brokenLine=Порушений рядок кешу робочого процесу GitHub. settings.cache.import.brokenKey=Зламаний ключ кешу робочого процесу GitHub. -settings.support.button=Підтримуйте цей плагін settings.support.tooltip=Відкрийте сторінку підтримки settings.support.line.0=Заправте піч для будівництва settings.support.line.1=Купити парсер кави settings.support.line.2=Спонсоруйте менше неприємних робочих процесів -workflow.run.jobs.title=Робочі процеси workflow.run.jobs.root=Запуск робочого процесу workflow.run.jobs.description=GitHub Дерево завдань дій і вибраний журнал завдань workflow.run.tree.done=зроблено diff --git a/src/main/resources/messages/GitHubWorkflowBundle_vi.properties b/src/main/resources/messages/GitHubWorkflowBundle_vi.properties index 6a4382a..0f6c0ca 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_vi.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_vi.properties @@ -1,15 +1,14 @@ -plugin.name=Quy trình làm việc của GitHub -plugin.description=Hỗ trợ các tệp quy trình làm việc của Hành động GitHub group.GitHubWorkflow.Tools.text=Quy trình làm việc của GitHub group.GitHubWorkflow.Tools.description=Công cụ bổ trợ quy trình làm việc GitHub -action.GitHubWorkflow.RefreshActionCache.text=Bộ đệm hành động Refresh -action.GitHubWorkflow.RefreshActionCache.description=Refresh đã giải quyết các Hành động GitHub từ xa và siêu dữ liệu quy trình làm việc có thể sử dụng lại +schema.auto={0} [Tự động] +action.GitHubWorkflow.RefreshActionCache.text=Làm mới bộ đệm hành động +action.GitHubWorkflow.RefreshActionCache.description=Làm mới metadata GitHub Actions từ xa đã phân giải và workflow có thể dùng lại action.GitHubWorkflow.RestoreActionWarnings.text=Khôi phục cảnh báo hành động action.GitHubWorkflow.RestoreActionWarnings.description=Khôi phục cảnh báo xác thực hành động, đầu vào và đầu ra bị chặn action.GitHubWorkflow.ClearActionCache.text=Xóa bộ nhớ đệm hành động action.GitHubWorkflow.ClearActionCache.description=Xóa các hành động GitHub được lưu trong bộ nhớ cache và siêu dữ liệu quy trình làm việc có thể sử dụng lại notification.cache.cleared=Đã xóa các mục nhập Luồng công việc GitHub được lưu trong bộ nhớ đệm {0}. -notification.cache.refresh.started=Refreshing {0} đã lưu các mục nhập Quy trình làm việc GitHub từ xa vào bộ nhớ đệm. +notification.cache.refresh.started=Đang làm mới {0} mục GitHub Workflow từ xa trong bộ đệm. notification.warnings.restored=Đã khôi phục cảnh báo cho các mục nhập Quy trình làm việc {0} GitHub. workflow.run.configuration.display=Quy trình làm việc của GitHub workflow.run.configuration.description=Gửi và theo dõi các lần chạy quy trình làm việc của Hành động GitHub @@ -26,10 +25,11 @@ workflow.run.error.repository=Cần phải có tên và chủ sở hữu kho lư workflow.run.error.workflow=Cần có tệp quy trình công việc. workflow.run.error.ref=Chi nhánh hoặc thẻ ref là bắt buộc. workflow.run.error.inputs=GitHub workflow_dispatch hỗ trợ tối đa 25 đầu vào. +workflow.run.error.html=GitHub trả về trang lỗi HTML thay vì dữ liệu API. workflow.run.gutter.stop=Dừng chạy quy trình công việc workflow.run.gutter.stop.text=Dừng chạy quy trình công việc workflow.run.gutter.stop.description=Hủy lần chạy này -workflow.run.auth.settings=Cài đặt> Kiểm soát phiên bản> GitHub +workflow.run.auth.settings=Cài đặt > Quản lý phiên bản > GitHub workflow.log.command=chạy: workflow.log.warning=cảnh báo: workflow.log.error=lỗi: @@ -113,6 +113,7 @@ documentation.servicePort.label=Cảng dịch vụ documentation.container.label=Vùng chứa công việc documentation.symbol.label=Biểu tượng quy trình làm việc documentation.symbol.description=Đã giải quyết biểu thức quy trình công việc. +documentation.workflowSyntax.label=Khóa workflow documentation.workflowOutput.label=Đầu ra của quy trình làm việc documentation.jobOutput.label=Đầu ra công việc documentation.action.label=hành động @@ -120,6 +121,9 @@ documentation.externalAction.label=Hành động bên ngoài documentation.reusableWorkflow.label=Quy trình làm việc có thể tái sử dụng documentation.resolvedFrom=được giải quyết từ {0} documentation.notResolved=chưa giải quyết được +documentation.manualInput=Đầu vào thêm thủ công +documentation.manualOutput=Đầu ra thêm thủ công +documentation.provider=Tài liệu workflow GitHub documentation.inputs.title=Đầu vào documentation.outputs.title=đầu ra documentation.secrets.title=Bí mật @@ -217,7 +221,6 @@ completion.uses.local.action=Hành động cục bộ completion.uses.ref.known=Tham chiếu quy trình làm việc đã biết completion.uses.ref.remote=Tham khảo quy trình làm việc từ xa completion.uses.remote.known=Hành động từ xa đã biết hoặc quy trình làm việc có thể sử dụng lại -completion.workflow.syntax=Cú pháp quy trình làm việc của Hành động GitHub completion.workflow.top.name=Tên hiển thị quy trình làm việc completion.workflow.top.run-name=Tên chạy động completion.workflow.top.on=Các sự kiện bắt đầu quy trình làm việc @@ -387,7 +390,7 @@ settings.cache.state.resolved=đã giải quyết settings.cache.state.pending=đang chờ xử lý settings.cache.state.expired=cũ kỹ settings.cache.state.suppressed=đàn áp -settings.cache.refresh=Bảng Refresh +settings.cache.refresh=Làm mới bảng settings.cache.deleteSelected=Xóa đã chọn settings.cache.deleteAll=Xóa tất cả settings.cache.export=Xuất khẩu @@ -402,12 +405,10 @@ settings.cache.import.done=Các mục bộ đệm đã nhập. Con thú lưu tr settings.cache.import.unsupported=Tệp bộ đệm quy trình làm việc GitHub không được hỗ trợ. settings.cache.import.brokenLine=Dòng bộ đệm quy trình làm việc GitHub bị hỏng. settings.cache.import.brokenKey=Khóa bộ đệm quy trình làm việc GitHub bị hỏng. -settings.support.button=Hỗ trợ plugin này settings.support.tooltip=Mở trang hỗ trợ settings.support.line.0=Cung cấp cho lò xây dựng settings.support.line.1=Mua cà phê phân tích cú pháp settings.support.line.2=Nhà tài trợ ít quy trình làm việc bị ám ảnh hơn -workflow.run.jobs.title=Công việc trong quy trình làm việc workflow.run.jobs.root=Chạy quy trình công việc workflow.run.jobs.description=GitHub Cây công việc hành động và nhật ký công việc đã chọn workflow.run.tree.done=xong diff --git a/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties b/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties index 6273f98..4c4d1ec 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties @@ -1,15 +1,14 @@ -plugin.name=GitHub 工作流程 -plugin.description=支持 GitHub 操作工作流程文件 group.GitHubWorkflow.Tools.text=GitHub 工作流程 group.GitHubWorkflow.Tools.description=GitHub 工作流程插件工具 -action.GitHubWorkflow.RefreshActionCache.text=Refresh 操作缓存 -action.GitHubWorkflow.RefreshActionCache.description=Refresh 解析了远程 GitHub 操作和可重用的工作流程元数据 +schema.auto={0} [自动] +action.GitHubWorkflow.RefreshActionCache.text=刷新操作缓存 +action.GitHubWorkflow.RefreshActionCache.description=刷新已解析的远程 GitHub Actions 和可复用工作流元数据 action.GitHubWorkflow.RestoreActionWarnings.text=恢复操作警告 action.GitHubWorkflow.RestoreActionWarnings.description=恢复抑制的操作、输入和输出验证警告 action.GitHubWorkflow.ClearActionCache.text=清除操作缓存 action.GitHubWorkflow.ClearActionCache.description=清除缓存的 GitHub 操作和可重用的工作流程元数据 notification.cache.cleared=已清除 {0} 缓存的 GitHub 工作流条目。 -notification.cache.refresh.started=Refreshing {0} 缓存了远程 GitHub 工作流条目。 +notification.cache.refresh.started=正在刷新 {0} 个缓存的远程 GitHub Workflow 条目。 notification.warnings.restored=恢复了 {0} GitHub 工作流程条目的警告。 workflow.run.configuration.display=GitHub 工作流程 workflow.run.configuration.description=调度并遵循 GitHub Actions 工作流程运行 @@ -26,10 +25,11 @@ workflow.run.error.repository=GitHub 存储库所有者和名称是必需的。 workflow.run.error.workflow=需要工作流程文件。 workflow.run.error.ref=需要分支或标签引用。 workflow.run.error.inputs=GitHub workflow_dispatch 最多支持 25 个输入。 +workflow.run.error.html=GitHub 返回了 HTML 错误页面,而不是 API 数据。 workflow.run.gutter.stop=停止工作流程运行 workflow.run.gutter.stop.text=停止工作流程运行 workflow.run.gutter.stop.description=取消本次运行 -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings=设置 > 版本控制 > GitHub workflow.log.command=运行: workflow.log.warning=警告: workflow.log.error=错误: @@ -113,6 +113,7 @@ documentation.servicePort.label=服务端口 documentation.container.label=作业容器 documentation.symbol.label=工作流程符号 documentation.symbol.description=已解决的工作流表达式。 +documentation.workflowSyntax.label=工作流键 documentation.workflowOutput.label=工作流程输出 documentation.jobOutput.label=作业输出 documentation.action.label=行动 @@ -120,6 +121,9 @@ documentation.externalAction.label=外部行动 documentation.reusableWorkflow.label=可重复使用的工作流程 documentation.resolvedFrom=从 {0} 解决 documentation.notResolved=尚未解决 +documentation.manualInput=手动添加的输入 +documentation.manualOutput=手动添加的输出 +documentation.provider=GitHub 工作流文档 documentation.inputs.title=输入 documentation.outputs.title=输出 documentation.secrets.title=秘密 @@ -217,7 +221,6 @@ completion.uses.local.action=当地行动 completion.uses.ref.known=已知的工作流程参考 completion.uses.ref.remote=远程工作流程参考 completion.uses.remote.known=已知的远程操作或可重复使用的工作流程 -completion.workflow.syntax=GitHub 操作工作流语法 completion.workflow.top.name=工作流程显示名称 completion.workflow.top.run-name=动态运行名称 completion.workflow.top.on=启动工作流程的事件 @@ -387,7 +390,7 @@ settings.cache.state.resolved=已解决 settings.cache.state.pending=待定 settings.cache.state.expired=陈旧的 settings.cache.state.suppressed=压制 -settings.cache.refresh=Refresh表 +settings.cache.refresh=刷新表格 settings.cache.deleteSelected=删除所选内容 settings.cache.deleteAll=全部删除 settings.cache.export=出口 @@ -402,12 +405,10 @@ settings.cache.import.done=导入的缓存条目。档案馆的野兽表现得 settings.cache.import.unsupported=不受支持的 GitHub 工作流缓存文件。 settings.cache.import.brokenLine=GitHub 工作流缓存线损坏。 settings.cache.import.brokenKey=GitHub 工作流缓存密钥损坏。 -settings.support.button=支持这个插件 settings.support.tooltip=打开支持页面 settings.support.line.0=向构建炉供料 settings.support.line.1=购买解析器咖啡 settings.support.line.2=赞助更少的闹鬼工作流程 -workflow.run.jobs.title=工作流程职位 workflow.run.jobs.root=工作流程运行 workflow.run.jobs.description=GitHub 操作作业树和选定的作业日志 workflow.run.tree.done=完成 diff --git a/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java b/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java index f1fb3fb..df08958 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java @@ -2,6 +2,8 @@ import com.github.yunabraska.githubworkflow.run.WorkflowRunConfiguration; +import com.github.yunabraska.githubworkflow.model.GitHubSchemaProvider; + import com.github.yunabraska.githubworkflow.state.GitHubActionCache; import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; @@ -95,6 +97,22 @@ public void testActionUpdateUsesConfiguredPluginLanguageOverride() { } } + public void testSchemaProviderNameUsesConfiguredPluginLanguageOverride() { + final GitHubWorkflowBundle.Settings settings = GitHubWorkflowBundle.Settings.getInstance(); + final String previousLanguage = settings.languageTag(); + try { + final GitHubSchemaProvider schema = new GitHubSchemaProvider("github-workflow", "GitHub Workflow", path -> true); + + assertThat(schema.getName()).isEqualTo("GitHub Workflow [Auto]"); + + settings.languageTag("de"); + + assertThat(schema.getName()).isEqualTo("GitHub Workflow [Automatisch]"); + } finally { + settings.languageTag(previousLanguage); + } + } + public void testWorkflowRunConfigurationIsRegistered() { final WorkflowRunConfiguration.Type type = ConfigurationTypeUtil.findConfigurationType(WorkflowRunConfiguration.Type.class); @@ -102,6 +120,15 @@ public void testWorkflowRunConfigurationIsRegistered() { assertThat(type.getConfigurationFactories()).hasSize(1); } + public void testSettingsConfigurableUsesLocalizedPluginXmlKey() throws IOException { + final String pluginXml = Files.readString(Path.of(System.getProperty("user.dir"), "src", "main", "resources", "META-INF", "plugin.xml")); + + assertThat(pluginXml) + .contains("key=\"settings.displayName\"") + .contains("bundle=\"messages.GitHubWorkflowBundle\"") + .doesNotContain("displayName=\"GitHub Workflow\""); + } + public void testPackagedSchemasArePresentAndNonEmpty() throws IOException { final Path directory = Path.of(System.getProperty("user.dir"), "src", "main", "resources", "schemas"); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java b/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java index 51c4902..3f8f2f1 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java @@ -1,7 +1,5 @@ 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; @@ -11,6 +9,9 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Properties; @@ -45,6 +46,8 @@ public class WorkflowMessagesTest { "vi", "zh_CN" ); + private static final Pattern ACTION_GROUP_ID = Pattern.compile("<(action|group)\\b[^>]*\\bid=\"([^\"]+)\""); + private static final Pattern XML_KEY_ATTRIBUTE = Pattern.compile("\\b(?:key|displayNameKey|descriptionKey)=\"([^\"]+)\""); @Test public void testDefaultBundleReturnsActionCacheMessages() { @@ -98,7 +101,6 @@ public void testLocaleBundleValuesAreTranslatedAndKeepPlaceholders() throws IOEx final Set technicalKeysAllowedToMatchEnglish = Set.of( "workflow.run.field.apiUrl", "workflow.run.field.ref", - "workflow.run.auth.settings", "workflow.log.error", "workflow.run.status", "workflow.run.job.status", @@ -135,6 +137,49 @@ public void testLocaleBundleValuesAreTranslatedAndKeepPlaceholders() throws IOEx } } + @Test + public void testLocalizedBundlesDoNotKeepKnownEnglishMergeLeftovers() throws IOException { + final List keys = List.of( + "notification.cache.refresh.started", + "action.GitHubWorkflow.RefreshActionCache.text", + "action.GitHubWorkflow.RefreshActionCache.description", + "settings.cache.refresh", + "workflow.run.auth.settings" + ); + for (final String suffix : LOCALE_SUFFIXES) { + final Properties bundle = loadBundle("_" + suffix); + for (final String key : keys) { + assertThat(bundle.getProperty(key)) + .as("Locale suffix [%s] key [%s] has no stale English merge text", suffix, key) + .doesNotContain("Refresh") + .doesNotContain("Refreshing") + .doesNotContain("Settings > Version Control") + .doesNotContain("Refоновлення"); + } + } + } + + @Test + public void testEveryDefaultBundleKeyHasAProductionConsumer() throws IOException { + final Set bundleKeys = loadBundle("").stringPropertyNames(); + final String productionJava = readTree(Path.of("src", "main", "java"), ".java"); + final Set usedKeys = new HashSet<>(); + + for (final String key : bundleKeys) { + if (productionJava.contains("\"" + key + "\"")) { + usedKeys.add(key); + } + } + + collectPluginXmlKeys(bundleKeys, usedKeys); + collectResourceTableKeys(bundleKeys, usedKeys); + collectDynamicKeyFamilies(productionJava, bundleKeys, usedKeys); + + assertThat(bundleKeys) + .as("Every message key must be wired by Java, plugin.xml, resource tables, or an explicit dynamic key family") + .containsExactlyInAnyOrderElementsOf(usedKeys); + } + @Test public void testEveryConfiguredLocaleResolvesSettingsAndInspectionMessages() { for (final String suffix : LOCALE_SUFFIXES) { @@ -266,12 +311,42 @@ public void testWorkflowSyntaxCompletionDescriptionsResolveForEveryLocale() { for (final String key : keys) { assertThat(GitHubWorkflowBundle.messageFor(locale, key)) .as("Locale suffix [%s] key [%s]", suffix, key) + .isNotBlank(); + } + } + } + + @Test + public void testWorkflowSyntaxTableMessageKeysResolveForEveryLocale() throws IOException { + final Set keys = workflowSyntaxMessageKeys(); + final Properties defaultBundle = loadBundle(""); + + assertThat(keys).isNotEmpty(); + assertThat(defaultBundle.stringPropertyNames()).containsAll(keys); + for (final String suffix : LOCALE_SUFFIXES) { + final Locale locale = Locale.forLanguageTag(suffix.replace('_', '-')); + for (final String key : keys) { + assertThat(GitHubWorkflowBundle.messageFor(locale, key)) + .as("Locale suffix [%s] syntax key [%s]", suffix, key) .isNotBlank() - .isNotEqualTo(GitHubWorkflowBundle.messageFor(locale, "completion.workflow.syntax")); + .doesNotContain("!" + key + "!"); } } } + @Test + public void testEveryWorkflowCompletionMessageKeyIsBackedBySyntaxTable() throws IOException { + final Set keys = workflowSyntaxMessageKeys(); + final Set bundleKeys = loadBundle("").stringPropertyNames(); + + assertThat(bundleKeys.stream() + .filter(key -> key.startsWith("completion.workflow.")) + .toList()) + .as("Workflow syntax completion messages must be reachable from the syntax table") + .isNotEmpty() + .allSatisfy(key -> assertThat(keys).contains(key)); + } + @Test public void testGermanInspectionAndCacheMessagesAreNotEnglishFallbacks() { final Locale locale = Locale.forLanguageTag("de"); @@ -330,6 +405,22 @@ public void testCoreInspectionMessagesAreLocalizedForEveryLocale() throws IOExce } } + private static Set workflowSyntaxMessageKeys() throws IOException { + final Set keys = new HashSet<>(); + for (final String line : Files.readAllLines(Path.of("src", "main", "resources", "github-docs", "workflow-syntax.tsv"), StandardCharsets.UTF_8)) { + final String trimmed = line.trim(); + if (trimmed.isBlank() || trimmed.startsWith("#")) { + continue; + } + final String[] parts = trimmed.split("\t", 3); + assertThat(parts) + .as("Workflow syntax table line [%s]", line) + .hasSize(3); + keys.add(parts[2]); + } + return keys; + } + private static Properties loadBundle(final String suffix) throws IOException { final String path = BUNDLE_PATH + suffix + ".properties"; try (InputStream stream = WorkflowMessagesTest.class.getClassLoader().getResourceAsStream(path)) { @@ -339,4 +430,64 @@ private static Properties loadBundle(final String suffix) throws IOException { return properties; } } + + private static String readTree(final Path root, final String suffix) throws IOException { + final StringBuilder builder = new StringBuilder(); + try (var paths = Files.walk(root)) { + for (final Path path : paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(suffix)) + .sorted() + .toList()) { + builder.append(Files.readString(path, StandardCharsets.UTF_8)).append('\n'); + } + } + return builder.toString(); + } + + private static void collectPluginXmlKeys(final Set bundleKeys, final Set usedKeys) throws IOException { + final String pluginXml = Files.readString(Path.of("src", "main", "resources", "META-INF", "plugin.xml"), StandardCharsets.UTF_8); + final var actionGroupMatcher = ACTION_GROUP_ID.matcher(pluginXml); + while (actionGroupMatcher.find()) { + final String prefix = "action".equals(actionGroupMatcher.group(1)) ? "action" : "group"; + usedKeys.add(prefix + "." + actionGroupMatcher.group(2) + ".text"); + usedKeys.add(prefix + "." + actionGroupMatcher.group(2) + ".description"); + } + + final var keyMatcher = XML_KEY_ATTRIBUTE.matcher(pluginXml); + while (keyMatcher.find()) { + final String key = keyMatcher.group(1); + if (bundleKeys.contains(key)) { + usedKeys.add(key); + } + } + } + + private static void collectResourceTableKeys(final Set bundleKeys, final Set usedKeys) throws IOException { + try (var paths = Files.walk(Path.of("src", "main", "resources"))) { + for (final Path path : paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".tsv")) + .sorted() + .toList()) { + for (final String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + final String[] parts = line.split("\t"); + if (parts.length >= 3 && bundleKeys.contains(parts[2])) { + usedKeys.add(parts[2]); + } + } + } + } + } + + private static void collectDynamicKeyFamilies(final String productionJava, final Set bundleKeys, final Set usedKeys) { + if (productionJava.contains("\"action.GitHubWorkflow.\" + key")) { + usedKeys.addAll(bundleKeys.stream() + .filter(key -> key.startsWith("action.GitHubWorkflow.")) + .toList()); + } + if (productionJava.contains("\"settings.support.line.\" +")) { + usedKeys.addAll(bundleKeys.stream() + .filter(key -> key.startsWith("settings.support.line.")) + .toList()); + } + } } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java index f1c970c..8fb2b69 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java @@ -4,6 +4,8 @@ import com.github.yunabraska.githubworkflow.entry.WorkflowDocumentationProvider; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + import com.github.yunabraska.githubworkflow.test.FakeRemoteServer; import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; @@ -272,6 +274,65 @@ public void testExpressionContextDocumentationShowsCollectionMeaning() { assertThat(documentationHintAtCaret()).contains("Output values exposed"); } + public void testWorkflowKeyDocumentationUsesConfiguredLanguage() { + final GitHubWorkflowBundle.Settings settings = GitHubWorkflowBundle.Settings.getInstance(); + final String previousLanguage = settings.languageTag(); + try { + settings.languageTag("de"); + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + + assertThat(documentationHintAtCaret()) + .contains("Workflow-Schlüssel steps") + .contains("Schrittliste. Die eigentliche Arbeit."); + } finally { + settings.languageTag(previousLanguage); + } + } + + public void testWorkflowTopKeyDocumentationUsesSyntaxTableDescription() { + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + env: + NODE_ENV: test + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + + assertThat(documentationHintAtCaret()) + .contains("Workflow key env") + .contains("Workflow-wide environment variables"); + } + + public void testWorkflowEventKeyDocumentationUsesSyntaxTableDescription() { + configureWorkflowProjectFile(""" + name: Docs + on: + pull_request_target: + types: [opened] + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + + assertThat(documentationHintAtCaret()) + .contains("Workflow key pull_request_target") + .contains("PR target context. Sharp knives."); + } + private String documentationHintAtCaret() { final WorkflowDocumentationProvider provider = new WorkflowDocumentationProvider(); final PsiElement context = elementAtCaret(); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java index c22e756..7e29745 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java @@ -2,6 +2,8 @@ import com.github.yunabraska.githubworkflow.entry.WorkflowCompletion; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + import com.github.yunabraska.githubworkflow.test.FakeRemoteServer; import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; @@ -869,6 +871,32 @@ public void testJobSyntaxCompletionSuggestsDocumentedJobKeys() { """)).contains("runs-on", "permissions", "environment", "strategy", "container", "services", "uses"); } + public void testWorkflowSyntaxCompletionDescriptionsUseConfiguredLanguageAfterEnglishTableUse() { + assertThat(completeWorkflowTypeTexts(""" + name: Completion + on: workflow_dispatch + jobs: + build: + + """)).containsEntry("steps", "Step list. The actual work."); + final GitHubWorkflowBundle.Settings settings = GitHubWorkflowBundle.Settings.getInstance(); + final String previousLanguage = settings.languageTag(); + try { + settings.languageTag("de"); + + assertThat(completeWorkflowTypeTexts(""" + name: Completion + on: workflow_dispatch + jobs: + build: + + """)).containsEntry("steps", "Schrittliste. Die eigentliche Arbeit.") + .containsEntry("environment", "Bereitstellungsumgebung"); + } finally { + settings.languageTag(previousLanguage); + } + } + public void testDefaultsRunCompletionSuggestsShellAndWorkingDirectory() { assertThat(completeWorkflow(""" name: Completion From 52ff8a42c2bb1d3441c2c32e0eea5ec98b7b2cfd Mon Sep 17 00:00:00 2001 From: Yuna Date: Sat, 20 Jun 2026 10:49:12 +0200 Subject: [PATCH 3/3] Harden Gitea workflow compatibility Harden Gitea workflow compatibility, settings, syntax, run tracking, embedded Gitea smoke tests, and local Docker discovery. --- .github/workflows/build.yml | 20 +- CHANGELOG.md | 4 + README.md | 17 +- ...02-configurable-remote-action-providers.md | 7 + doc/navigation.md | 1 + doc/spec/editor-test-matrix.md | 6 + .../gitea-github-actions-compatibility.md | 56 ++ .../entry/WorkflowAnnotator.java | 134 ++++- .../entry/WorkflowCompletion.java | 55 +- .../git/RemoteActionProviders.java | 524 ++++++++++++++++-- .../githubworkflow/run/WorkflowRun.java | 185 ++++++- .../run/WorkflowRunProcessHandler.java | 27 +- .../GitHubWorkflowSettingsConfigurable.java | 64 ++- .../settings/GiteaSettingsConfigurable.java | 365 ++++++++++++ .../githubworkflow/syntax/Envs.java | 8 +- .../githubworkflow/syntax/Secrets.java | 7 +- .../syntax/WorkflowContextCatalog.java | 72 ++- .../githubworkflow/syntax/WorkflowSyntax.java | 181 +++++- .../githubworkflow/syntax/WorkflowYaml.java | 22 + src/main/resources/META-INF/plugin.xml | 6 + .../resources/github-docs/workflow-syntax.tsv | 19 + .../messages/GitHubWorkflowBundle.properties | 25 +- .../GitHubWorkflowBundle_ar.properties | 25 +- .../GitHubWorkflowBundle_cs.properties | 25 +- .../GitHubWorkflowBundle_de.properties | 25 +- .../GitHubWorkflowBundle_es.properties | 25 +- .../GitHubWorkflowBundle_fr.properties | 25 +- .../GitHubWorkflowBundle_hi.properties | 25 +- .../GitHubWorkflowBundle_id.properties | 25 +- .../GitHubWorkflowBundle_it.properties | 25 +- .../GitHubWorkflowBundle_ja.properties | 25 +- .../GitHubWorkflowBundle_ko.properties | 25 +- .../GitHubWorkflowBundle_nl.properties | 25 +- .../GitHubWorkflowBundle_pl.properties | 25 +- .../GitHubWorkflowBundle_pt_BR.properties | 25 +- .../GitHubWorkflowBundle_ru.properties | 25 +- .../GitHubWorkflowBundle_sv.properties | 25 +- .../GitHubWorkflowBundle_th.properties | 25 +- .../GitHubWorkflowBundle_tr.properties | 25 +- .../GitHubWorkflowBundle_uk.properties | 25 +- .../GitHubWorkflowBundle_vi.properties | 25 +- .../GitHubWorkflowBundle_zh_CN.properties | 25 +- .../entry/PluginWiringTest.java | 144 +++++ .../git/GiteaDockerIntegrationTest.java | 479 ++++++++++++++++ .../git/RemoteActionProvidersTest.java | 229 +++++++- .../i18n/WorkflowMessagesTest.java | 13 +- .../run/WorkflowRunProcessHandlerTest.java | 45 ++ .../githubworkflow/run/WorkflowRunTest.java | 130 ++++- .../syntax/WorkflowMetadataTest.java | 18 +- .../syntax/WorkflowSyntaxCompletionTest.java | 121 ++++ .../syntax/WorkflowValidationTest.java | 82 +++ .../test/EditorFeatureTestCase.java | 22 +- 52 files changed, 3413 insertions(+), 175 deletions(-) create mode 100644 doc/spec/gitea-github-actions-compatibility.md create mode 100644 src/main/java/com/github/yunabraska/githubworkflow/settings/GiteaSettingsConfigurable.java create mode 100644 src/test/java/com/github/yunabraska/githubworkflow/git/GiteaDockerIntegrationTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 77b7020..0655e38 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,6 +145,12 @@ jobs: distribution: temurin java-version: 25 + - name: 🐳 Check Docker + shell: sh + run: | + docker version + docker info + - name: 🏷️ Prepare release metadata if: steps.plan.outputs.release == 'true' shell: sh @@ -309,11 +315,11 @@ jobs: fi curl "$@" https://plugins.jetbrains.com/api/updates/upload - - name: 📤 Push release commit and tag + - name: 📤 Push release commit + id: release_source if: steps.plan.outputs.release == 'true' && steps.plan.outputs.dry_run != 'true' shell: sh env: - RELEASE_TAG: ${{ steps.plan.outputs.tag }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | git config user.name "Kira" @@ -324,9 +330,9 @@ jobs: fi target_branch="${GITHUB_REF_NAME:-main}" + release_target="$(git rev-parse HEAD)" git push origin "HEAD:$target_branch" - git tag -a "$RELEASE_TAG" -m "$RELEASE_TAG [skip ci]" - git push origin "refs/tags/$RELEASE_TAG" + echo "target=$release_target" >> "$GITHUB_OUTPUT" - name: 🚀 Create GitHub release if: steps.plan.outputs.release == 'true' && steps.plan.outputs.dry_run != 'true' @@ -335,12 +341,16 @@ jobs: ARCHIVE_PATH: ${{ steps.archive.outputs.path }} GH_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }} RELEASE_TAG: ${{ steps.plan.outputs.tag }} + RELEASE_TARGET: ${{ steps.release_source.outputs.target }} run: | if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then gh release upload "$RELEASE_TAG" "$ARCHIVE_PATH" --clobber gh release edit "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file release-notes.md else - gh release create "$RELEASE_TAG" "$ARCHIVE_PATH" --title "$RELEASE_TAG" --notes-file release-notes.md --verify-tag + if ! gh release create "$RELEASE_TAG" "$ARCHIVE_PATH" --title "$RELEASE_TAG" --notes-file release-notes.md --target "$RELEASE_TARGET"; then + gh release delete "$RELEASE_TAG" --yes --cleanup-tag >/dev/null 2>&1 || true + exit 1 + fi fi - name: 💾 Save cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab3979..11c04e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - 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. +- Gitea workflow runs now use `GITEA_TOKEN`-style auth, clean browser links, and repo-level run discovery for `/api/v1`. +- `.gitea/workflows/*` syntax now uses Gitea permission scopes and cron aliases instead of GitHub-only scope clutter. +- `.gitea/workflows/*` completion and validation now know `GITEA_TOKEN`, Gitea runner env vars, single-label `runs-on`, + documented expression functions, and job keys Gitea accepts but ignores. ## [2026.5.29] - 2026-05-29 diff --git a/README.md b/README.md index fccd47b..c7cf354 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ _[See Screenshots](https://plugins.jetbrains.com/plugin/21396-github-workflow)_ matching IDE accounts first, then other IDE GitHub accounts, then `GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_PAT`, then anonymous access. An optional token environment variable can still be set explicitly for custom setups. GitHub log timestamps, groups, command markers, and ANSI color codes are compacted before display. -* **Usage**: Enjoy autocomplete, syntax highlighting, and much more as you code your GitHub Workflows and Actions. +* **Usage**: Enjoy autocomplete, syntax highlighting, and much more as you code GitHub or Gitea Workflows and Actions. ## Local Development @@ -74,9 +74,22 @@ The project uses the Gradle wrapper and Java 25. No manual JetBrains JDK path is Plugin downloads the IDE, bundled plugins, verifier, and test runtime. 1. Install Java 25 and make it available as `java`. -2. Run `./gradlew test` for the fast regression suite. +2. Run `./gradlew test` for the regression suite, including the Docker-backed Gitea smoke test. 3. Run `./gradlew check verifyPlugin buildPlugin` before publishing or opening a release PR. +Gitea smoke test controls: + +```sh +./gradlew test --tests com.github.yunabraska.githubworkflow.git.GiteaDockerIntegrationTest --rerun-tasks +GITEA_DOCKER_TEST=false ./gradlew test +``` + +That starts the official rootless Gitea Docker image, seeds a tiny repository, and checks action plus `.gitea/workflows` +metadata through the same remote resolver. Set `GITEA_DOCKER_TEST=false` to skip it locally, or override the image with +`GITEA_IMAGE` when testing another Gitea release. If `docker` is not on the Gradle process `PATH`, set +`GITEA_DOCKER_BIN`, `DOCKER_BIN`, or `DOCKER_CLI` to the Docker executable; the test also tries `command -v docker`, +`which docker`, `where docker`, and common Docker Desktop/Homebrew locations. + ## Release Automation One workflow handles tagging, packaging, GitHub Packages, Marketplace publishing, changelog notes, and GitHub releases. diff --git a/doc/adr/0002-configurable-remote-action-providers.md b/doc/adr/0002-configurable-remote-action-providers.md index 6c01879..2d74222 100644 --- a/doc/adr/0002-configurable-remote-action-providers.md +++ b/doc/adr/0002-configurable-remote-action-providers.md @@ -24,11 +24,18 @@ tokens. Use a JDK fake HTTP server in tests for GitHub Enterprise-shaped API behavior. Tests may inject temporary server definitions directly into the service; that is not a user-facing settings surface. +Reuse the same provider boundary for Gitea-compatible API behavior where possible. Gitea differs by using `/api/v1` and +`Authorization: token ...`, so tests cover that provider type explicitly. Do not add a Gitea account UI unless real +user-facing configuration becomes necessary. + ## Consequences Self-hosted GitHub action metadata can be resolved, linked, highlighted, styled, documented, and completed without contacting public GitHub in tests. +Gitea action and `.gitea/workflows` metadata can be tested through fake `/api/v1` responses and a default-on Docker +smoke test without duplicating the GitHub resolver. `GITEA_DOCKER_TEST=false` keeps local escape hatches explicit. + The plugin stays boring: no duplicate account UI, no token storage, and fewer settings to test. The provider currently resolves metadata and refs after a callable is known. It does not browse arbitrary remote diff --git a/doc/navigation.md b/doc/navigation.md index ad13167..dd33099 100644 --- a/doc/navigation.md +++ b/doc/navigation.md @@ -38,6 +38,7 @@ Editor behavior should be tested through IntelliJ fixture entrypoints. See: - `doc/adr/0008-test-through-editor-and-runtime-boundaries.md` - `doc/spec/editor-test-matrix.md` +- `doc/spec/gitea-github-actions-compatibility.md` Remote GitHub behavior should use fake HTTP servers or explicit client boundaries. Network access in tests is guilty until proven innocent. diff --git a/doc/spec/editor-test-matrix.md b/doc/spec/editor-test-matrix.md index 35af12c..f55a45b 100644 --- a/doc/spec/editor-test-matrix.md +++ b/doc/spec/editor-test-matrix.md @@ -21,6 +21,10 @@ Official syntax references: - Resolved actions with cached major-version refs show an update quick-fix for older `v?\d+` refs. - Remote `uses` ref completion for tags/branches resolved from public GitHub and GitHub Enterprise-shaped servers through fake HTTP servers. +- Gitea-compatible `/api/v1` remote metadata resolution is covered through fake HTTP tests and a default-on Docker smoke + test against the official rootless Gitea image. The same Docker suite also registers `act_runner`, dispatches a real + `.gitea/workflows` run through the plugin client, waits for completion, lists jobs, and downloads logs. Set + `GITEA_DOCKER_TEST=false` to skip it. - GitHub Enterprise servers registered in JetBrains GitHub settings are used as remote metadata sources; the plugin does not add a parallel server settings UI. - Cache actions and settings are registered through `plugin.xml`, localized through resource bundles, and covered by @@ -56,6 +60,8 @@ Official syntax references: `runner.*`, including `runner.debug`, `job.*`, nested `job.container.*`, strict local `job.services.*`, `strategy.*`, `matrix.*`, and unknown external `vars.*` contexts. - `gitea.*` context highlighting and completion uses the same key map as `github.*`. +- `.gitea/workflows/*` syntax uses Gitea token permission scopes, Gitea permission shorthand values, and Gitea cron + alias completion without leaking GitHub-only permission scopes from the bundled GitHub schema. - `job.services.` validation/completion/reference/styling from local job service definitions, including `id`, `network`, `ports`, and mapped port keys. - Matrix keys from direct `strategy.matrix` entries and `strategy.matrix.include`. - `steps..outputs.` validation and completion for previous run outputs, multiline `$GITHUB_OUTPUT`, `tee -a $GITHUB_OUTPUT`, and resolved action outputs. diff --git a/doc/spec/gitea-github-actions-compatibility.md b/doc/spec/gitea-github-actions-compatibility.md new file mode 100644 index 0000000..07288c3 --- /dev/null +++ b/doc/spec/gitea-github-actions-compatibility.md @@ -0,0 +1,56 @@ +# Gitea And GitHub Actions Compatibility + +Last checked: 2026-06-11. + +This note tracks behavior where Gitea Actions is close to GitHub Actions, but not identical. The plugin should prefer +shared behavior first and branch only where Gitea really differs. Tiny forks, not a hydra. + +## Sources + +- Gitea Actions comparison: https://docs.gitea.com/usage/actions/comparison +- Gitea Actions job token permissions: https://docs.gitea.com/usage/actions/token-permissions +- Gitea Actions variables and `gitea.*` context: https://docs.gitea.com/usage/actions/actions-variables +- Gitea API reference and OpenAPI spec: https://docs.gitea.com/api/ +- Gitea OpenAPI source used for endpoint checks: https://docs.gitea.com/redocusaurus/plugin-redoc-1.yaml +- GitHub workflow REST API: https://docs.github.com/en/rest/actions/workflows?apiVersion=2026-03-10 +- GitHub workflow run REST API: https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10 +- GitHub workflow job REST API: https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2026-03-10 +- GitHub artifact REST API: https://docs.github.com/en/rest/actions/artifacts?apiVersion=2026-03-10 + +## Already Handled + +| Area | GitHub | Gitea | Plugin behavior | +| --- | --- | --- | --- | +| Workflow home | `.github/workflows/*` | `.gitea/workflows/*` | Both workflow roots are detected. | +| API base | `https://api.github.com` or enterprise `/api/v3` | Instance `/api/v1` | Run and metadata code infer provider behavior from API URL and workflow path. | +| API auth header | `Authorization: Bearer ...` | `Authorization: token ...` | Gitea providers and runs use `token ...`; GitHub keeps IDE account plus bearer token priority. | +| Token env fallback | `GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_PAT` | `GITEA_TOKEN`, `GITEA_PAT` | Gitea runs and metadata use Gitea token env names before anonymous access. | +| Workflow dispatch result | GitHub returns run details on current API version | Gitea returns run details when `return_run_details=true` | Gitea dispatch adds `return_run_details=true`; both parse `workflow_run_id`, `run_url`, and `html_url`. | +| Run discovery | Workflow-scoped or repository-scoped run listing | Repository-scoped `actions/runs` with `limit` and run `path` | GitHub keeps workflow-scoped discovery; Gitea reads a small repo-level window and picks the same workflow closest to dispatch time. | +| Job logs | `/actions/jobs/{job_id}/logs` | Same path in Gitea OpenAPI | Existing job log download path is shared. | +| Run jobs | `/actions/runs/{run}/jobs` | Same path in Gitea OpenAPI | Existing job tree polling path is shared. | +| Artifacts | Run artifact list plus artifact ZIP | Same core paths in Gitea OpenAPI | Existing artifact list/download path is shared. | +| Context root | `github.*` | `gitea.*`, with `github.*` as alias | Completion/highlighting covers `gitea.*` using the GitHub-compatible key map. | +| Absolute action URLs | GitHub syntax is usually `owner/repo[/path]@ref` | Absolute URLs are supported | Remote resolver supports absolute URLs for configured servers. | +| Cron aliases | GitHub uses POSIX cron expressions | Gitea also accepts `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly` | `.gitea/workflows` cron completion suggests the Gitea aliases. | +| Permission scopes | GitHub has GitHub-only scopes such as `id-token`, `statuses`, `pages` | Gitea has `code`, `releases`, `wiki`, `projects`, and a smaller shared scope set | `.gitea/workflows` completion and validation use the Gitea scope set. | +| Permission shorthand | GitHub completion includes `read-all`, `write-all`, and `{}` | Gitea documents `read-all` and `write-all` scalar values | `.gitea/workflows` shorthand completion stays on documented Gitea values. | +| Complex `runs-on` | GitHub accepts richer runner targeting | Gitea supports scalar or single-item array forms | `.gitea/workflows` warns on multi-label arrays without affecting GitHub files. | +| Expression functions | GitHub has a wider function set | Gitea documents `always()` only | `.gitea/workflows` completion offers `always()` and warns on known GitHub-only functions. | +| Ignored job keys | GitHub executes `timeout-minutes`, `continue-on-error`, and `environment` | Gitea accepts but ignores them | `.gitea/workflows` keeps the keys valid and labels them as accepted runtime no-ops. | +| Runtime names | GitHub exposes `GITHUB_TOKEN` and `GITHUB_*` names | Gitea exposes `GITEA_TOKEN` and `GITEA_*` names too | `.gitea/workflows` completion prefers `secrets.GITEA_TOKEN` and includes Gitea runner env variables. | + +## Known Limitations / Do Not Guess + +| Area | Gitea behavior | Plugin risk | Suggested handling | +| --- | --- | --- | --- | +| Problem matchers and annotations | Gitea ignores problem matchers and workflow command annotations. | Log rendering can still color warnings/errors locally; remote UI may not. | No code change needed unless we add Gitea-specific docs. | +| Default action source | Gitea may resolve unqualified `uses:` through instance config (`github` or `self`). | Plugin cannot know server admin config. | Keep configured-server absolute URL support; do not guess admin config. | +| Secret and variable names | Gitea disallows user-created names starting with `GITHUB_` or `GITEA_`; variables are uppercased. | Future settings/UI for external variables must enforce Gitea naming rules. | Built-in names are completed; external variable CRUD does not exist yet. | + +## Test Shape + +- Fake HTTP tests cover provider inference, auth header scheme, dispatch URL, and run discovery URL. +- The Docker-backed Gitea integration test runs by default and seeds tiny repositories to verify `/api/v1` metadata + resolution plus a real `act_runner` workflow dispatch, run completion, job listing, and log download. +- Keep Docker test opt-out explicit with `GITEA_DOCKER_TEST=false` for machines where Docker is unavailable. diff --git a/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java index cc89dd6..460a5a7 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java @@ -25,6 +25,8 @@ import com.intellij.psi.impl.source.tree.LeafPsiElement; import org.jetbrains.annotations.NotNull; import org.jetbrains.yaml.psi.YAMLKeyValue; +import org.jetbrains.yaml.psi.YAMLSequence; +import org.jetbrains.yaml.psi.YAMLSequenceItem; import java.util.ArrayList; import java.util.Collection; @@ -32,8 +34,11 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -94,12 +99,16 @@ public class WorkflowAnnotator implements Annotator { "GITHUB_WORKFLOW_SCALAR_LITERAL", DefaultLanguageHighlighterColors.NUMBER ); + private static final Pattern EXPRESSION_FUNCTION = Pattern.compile("\\b([A-Za-z_][A-Za-z0-9_]*)\\s*\\("); + private static final Set GITHUB_EXPRESSION_FUNCTIONS = Set.copyOf(githubExpressionFunctionNames()); + private static final Set GITEA_EXPRESSION_FUNCTIONS = Set.of("always"); @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::validateGiteaExpressionFunctions) .then(WorkflowAnnotator::highlightVariableReferences) .then(WorkflowAnnotator::highlightDeclarations) .then(WorkflowAnnotator::highlightRunOutputs) @@ -155,7 +164,7 @@ private static void highlightRunnerVariables(final AnnotationHolder holder, fina .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))); + .ifPresent(element -> defaultEnvs(WorkflowSyntax.providerFor(element)).keySet().forEach(name -> highlightWord(holder, element, name, RUNNER_VARIABLE))); } private static void highlightWord( @@ -196,7 +205,12 @@ private static void validateWorkflowSyntax(final AnnotationHolder holder, final } WorkflowLocation.from(psiElement) .filter(WorkflowAnnotator::shouldValidateWorkflowSyntax) - .ifPresent(location -> validateWorkflowKeyValue(holder, location.keyValue(), location.path())); + .ifPresent(location -> validateWorkflowKeyValue( + holder, + location.keyValue(), + location.path(), + WorkflowSyntax.providerFor(location.keyValue()) + )); } private static boolean shouldValidateWorkflowSyntax(final WorkflowLocation location) { @@ -208,17 +222,23 @@ private static boolean isUnitTestWorkflowFile(final YAMLKeyValue element) { && WorkflowPsi.getChild(element.getContainingFile(), "runs").isEmpty(); } - private static void validateWorkflowKeyValue(final AnnotationHolder holder, final YAMLKeyValue element, final List path) { - WorkflowSyntax.validationKeysForPath(path).ifPresent(keys -> { + private static void validateWorkflowKeyValue( + final AnnotationHolder holder, + final YAMLKeyValue element, + final List path, + final WorkflowSyntax.Provider provider + ) { + WorkflowSyntax.validationKeysForPath(path, provider).ifPresent(keys -> { validateKnownKey(holder, element, keys.values(), keys.messageKey()); - validateWorkflowPropertyValue(holder, element, path); + validateWorkflowPropertyValue(holder, element, path, provider); }); } private static void validateWorkflowPropertyValue( final AnnotationHolder holder, final YAMLKeyValue element, - final List path + final List path, + final WorkflowSyntax.Provider provider ) { final String key = element.getKeyText(); if (isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) @@ -229,11 +249,31 @@ private static void validateWorkflowPropertyValue( 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"); + validateKnownValue(holder, element, WorkflowSyntax.eventActivityTypesFor(path.get(1), provider), "inspection.workflow.syntax.unknownTriggerValue"); } if (pathEndsWith(path, "permissions")) { - validateKnownValue(holder, element, WorkflowSyntax.permissionValuesFor(key), "inspection.workflow.syntax.unknownPermissionValue"); + validateKnownValue(holder, element, WorkflowSyntax.permissionValuesFor(key, provider), "inspection.workflow.syntax.unknownPermissionValue"); + } + if (provider == WorkflowSyntax.Provider.GITEA && pathMatches(path, FIELD_JOBS, "*") && "runs-on".equals(key)) { + validateGiteaRunsOn(holder, element); + } + } + + private static void validateGiteaRunsOn(final AnnotationHolder holder, final YAMLKeyValue element) { + if (!(element.getValue() instanceof YAMLSequence sequence)) { + return; } + final List items = WorkflowPsi.getChildren(sequence, YAMLSequenceItem.class); + if (items.size() <= 1) { + return; + } + new SyntaxAnnotation( + GitHubWorkflowBundle.message("inspection.workflow.syntax.giteaRunsOnSingleLabel"), + null, + HighlightSeverity.WEAK_WARNING, + ProblemHighlightType.WEAK_WARNING, + null + ).createAnnotation(element, sequence.getTextRange(), holder); } private static void validateWorkflowInputPropertyValue( @@ -402,6 +442,84 @@ private static void variableElementHandler(final AnnotationHolder holder, final ); } + private static void validateGiteaExpressionFunctions(final AnnotationHolder holder, final PsiElement psiElement) { + Optional.of(psiElement) + .filter(LeafPsiElement.class::isInstance) + .map(LeafPsiElement.class::cast) + .filter(element -> WorkflowSyntax.providerFor(element) == WorkflowSyntax.Provider.GITEA) + .filter(isElementWithVariables(getParent(psiElement, FIELD_IF).orElse(null))) + .ifPresent(element -> expressionRanges(element).forEach(range -> validateGiteaExpressionRange(holder, element, range))); + } + + private static List expressionRanges(final LeafPsiElement element) { + final String text = element.getText(); + if (getParent(element, FIELD_IF).isPresent()) { + return text.isBlank() ? List.of() : List.of(new TextRange(0, text.length())); + } + final List result = new ArrayList<>(); + int index = 0; + while (index < text.length()) { + final int start = text.indexOf("${{", index); + if (start < 0) { + break; + } + final int bodyStart = start + 3; + final int end = text.indexOf("}}", bodyStart); + if (end < 0) { + break; + } + result.add(new TextRange(bodyStart, end)); + index = end + 2; + } + return result; + } + + private static void validateGiteaExpressionRange(final AnnotationHolder holder, final LeafPsiElement element, final TextRange relativeRange) { + final String body = element.getText().substring(relativeRange.getStartOffset(), relativeRange.getEndOffset()); + final Matcher matcher = EXPRESSION_FUNCTION.matcher(body); + while (matcher.find()) { + if (insideQuotedString(body, matcher.start(1))) { + continue; + } + final String name = matcher.group(1); + if (!GITHUB_EXPRESSION_FUNCTIONS.contains(name) || GITEA_EXPRESSION_FUNCTIONS.contains(name)) { + continue; + } + final int start = element.getTextRange().getStartOffset() + relativeRange.getStartOffset() + matcher.start(1); + final int end = element.getTextRange().getStartOffset() + relativeRange.getStartOffset() + matcher.end(1); + new SyntaxAnnotation( + GitHubWorkflowBundle.message("inspection.workflow.syntax.giteaExpressionFunction", name + "()"), + null, + HighlightSeverity.WEAK_WARNING, + ProblemHighlightType.WEAK_WARNING, + null + ).createAnnotation(element, new TextRange(start, end), holder); + } + } + + private static boolean insideQuotedString(final String text, final int offset) { + char quote = 0; + for (int index = 0; index < text.length() && index < offset; index++) { + final char current = text.charAt(index); + if (quote == '\'') { + if (current == '\'' && index + 1 < text.length() && text.charAt(index + 1) == '\'') { + index++; + } else if (current == '\'') { + quote = 0; + } + } else if (quote == '"') { + if (current == '\\' && index + 1 < text.length()) { + index++; + } else if (current == '"') { + quote = 0; + } + } else if (current == '\'' || current == '"') { + quote = current; + } + } + return quote != 0; + } + private static void highlightContext( final AnnotationHolder holder, final LeafPsiElement element, diff --git a/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowCompletion.java b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowCompletion.java index d052f6f..1231974 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowCompletion.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowCompletion.java @@ -55,7 +55,6 @@ import java.util.Map; import java.util.Optional; import java.nio.file.Path; -import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -185,12 +184,9 @@ 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, + defaultEnvs(WorkflowSyntax.providerFor(trigger.position())), NodeIcon.ICON_ENV, Character.MIN_VALUE ); @@ -246,7 +242,7 @@ private static boolean completeShell(final CompletionTrigger trigger) { } addLookupElements( trigger.resultSet().withPrefixMatcher(getDefaultPrefix(trigger.parameters())), - SHELLS, + shells(), NodeIcon.ICON_NODE, Character.MIN_VALUE ); @@ -355,15 +351,16 @@ private static Optional workflowStructureCompletion(final C return Optional.empty(); } final List path = context.get().path(); - final Optional> keys = WorkflowSyntax.completionKeysForPath(path); + final WorkflowSyntax.Provider provider = WorkflowSyntax.providerFor(completionPsi.position()); + final Optional> keys = WorkflowSyntax.completionKeysForPath(path, provider); if (keys.isPresent()) { return Optional.of(new StructureCompletion(keys.get(), ':')); } if (pathMatches(path, FIELD_ON, "*", "types")) { - return Optional.of(new StructureCompletion(WorkflowSyntax.eventActivityTypesFor(path.get(1)), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.eventActivityTypesFor(path.get(1), provider), Character.MIN_VALUE)); } if (pathMatches(path, FIELD_ON, "*", "*")) { - return workflowEventFilterValueCompletion(completionPsi, context.get()); + return workflowEventFilterValueCompletion(completionPsi, context.get(), provider); } return Optional.empty(); } @@ -381,19 +378,20 @@ private static Optional workflowValueCompletion(final Compl return Optional.empty(); } final List path = context.get().path(); + final WorkflowSyntax.Provider provider = WorkflowSyntax.providerFor(completionPsi.position()); final String currentKey = key.get(); - final Optional eventFilterValueCompletion = workflowEventFilterValueCompletion(completionPsi, context.get()); + final Optional eventFilterValueCompletion = workflowEventFilterValueCompletion(completionPsi, context.get(), provider); if (eventFilterValueCompletion.isPresent()) { return eventFilterValueCompletion; } if ("runs-on".equals(currentKey)) { - return Optional.of(new StructureCompletion(WorkflowSyntax.runnerLabels(), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.runnerLabels(provider), Character.MIN_VALUE)); } if ("permissions".equals(currentKey)) { - return Optional.of(new StructureCompletion(WorkflowSyntax.permissionShorthandValues(), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.permissionShorthandValues(provider), Character.MIN_VALUE)); } if ("types".equals(currentKey) && pathMatches(path, FIELD_ON, "*")) { - return Optional.of(new StructureCompletion(WorkflowSyntax.eventActivityTypesFor(path.get(1)), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.eventActivityTypesFor(path.get(1), provider), Character.MIN_VALUE)); } if ("type".equals(currentKey) && (isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) @@ -401,7 +399,7 @@ private static Optional workflowValueCompletion(final Compl return Optional.of(new StructureCompletion(WorkflowSyntax.workflowInputTypesFor(path.get(1)), Character.MIN_VALUE)); } if (pathEndsWith(path, "permissions")) { - return Optional.of(new StructureCompletion(WorkflowSyntax.permissionValuesFor(currentKey), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.permissionValuesFor(currentKey, provider), Character.MIN_VALUE)); } if ("required".equals(currentKey) || "continue-on-error".equals(currentKey) @@ -412,39 +410,49 @@ private static Optional workflowValueCompletion(final Compl return Optional.empty(); } - private static Optional workflowEventFilterValueCompletion(final CompletionPsi completionPsi, final WorkflowLocation.KeyContext context) { - return eventFilterContext(context) - .map(eventFilter -> eventFilterValueCompletions(completionPsi.position(), eventFilter.event(), eventFilter.filter())) + private static Optional workflowEventFilterValueCompletion( + final CompletionPsi completionPsi, + final WorkflowLocation.KeyContext context, + final WorkflowSyntax.Provider provider + ) { + return eventFilterContext(context, provider) + .map(eventFilter -> eventFilterValueCompletions(completionPsi.position(), eventFilter.event(), eventFilter.filter(), provider)) .filter(values -> !values.isEmpty()) .map(values -> new StructureCompletion(values, Character.MIN_VALUE)); } - private static Optional eventFilterContext(final WorkflowLocation.KeyContext context) { + private static Optional eventFilterContext(final WorkflowLocation.KeyContext context, final WorkflowSyntax.Provider provider) { 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 WorkflowSyntax.eventFilterKeysFor(event).containsKey(filter) + return WorkflowSyntax.eventFilterKeysFor(event, provider).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 WorkflowSyntax.eventFilterKeysFor(event).containsKey(filter) + return WorkflowSyntax.eventFilterKeysFor(event, provider).containsKey(filter) ? Optional.of(new EventFilterContext(event, filter)) : Optional.empty(); } return Optional.empty(); } - private static Map eventFilterValueCompletions(final PsiElement position, final String event, final String filter) { + private static Map eventFilterValueCompletions( + final PsiElement position, + final String event, + final String filter, + final WorkflowSyntax.Provider provider + ) { return switch (filter) { - case "types" -> WorkflowSyntax.eventActivityTypesFor(event); + case "types" -> WorkflowSyntax.eventActivityTypesFor(event, provider); 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); + case "cron" -> WorkflowSyntax.cronValues(provider); default -> Collections.emptyMap(); }; } @@ -851,8 +859,7 @@ private static List codeCompletionContext(final String field, fin } private static void addDefaultWorkflowCompletionItems(final int i, final PsiElement position, final Map> completionItemMap) { - ofNullable(DEFAULT_VALUE_MAP.getOrDefault(FIELD_DEFAULT, null)) - .map(Supplier::get) + ofNullable(expressionRoots(WorkflowSyntax.providerFor(position))) .map(map -> { final Map copyMap = new HashMap<>(map); Optional.of(listInputs(position)).filter(List::isEmpty).ifPresent(empty -> copyMap.remove(FIELD_INPUTS)); diff --git a/src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java b/src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java index c5fb553..e18d0f8 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java @@ -6,11 +6,21 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.intellij.credentialStore.CredentialAttributes; +import com.intellij.credentialStore.Credentials; +import com.intellij.ide.passwordSafe.PasswordSafe; import com.intellij.ide.impl.ProjectUtil; +import com.intellij.openapi.application.Application; 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.Logger; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManager; +import com.intellij.util.xmlb.XmlSerializerUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.plugins.github.authentication.GHAccountsUtil; import org.jetbrains.plugins.github.authentication.accounts.GithubAccount; import org.jetbrains.plugins.github.util.GHCompatibilityUtil; @@ -30,8 +40,9 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; -import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; import java.util.function.Function; import java.util.function.Predicate; @@ -136,7 +147,7 @@ private static List metadataPaths(final Server server, final RemoteUses private static boolean isWorkflowPath(final String path) { final String normalized = path.replace('\\', '/'); - return normalized.contains(".github/workflows/") + return (normalized.contains(".github/workflows/") || normalized.contains(".gitea/workflows/")) && (normalized.endsWith(".yml") || normalized.endsWith(".yaml")); } @@ -187,7 +198,7 @@ private static Map searchUses(final Server server, final RemoteU } private static Optional getJson(final Server server, final String url) { - for (final RemoteActionProviders.Authorizations.Authorization authorization : RemoteActionProviders.Authorizations.forApiUrl(server.apiUrl, server.tokenEnvVar, null)) { + for (final RemoteActionProviders.Authorizations.Authorization authorization : RemoteActionProviders.Authorizations.forServer(server, null)) { try { final HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofSeconds(3)) @@ -371,20 +382,161 @@ static Optional parse(final Server server, final String value) } } - public static class Settings { + @State(name = "GitHubWorkflowRemoteSettings", storages = {@Storage("githubWorkflowRemoteSettings.xml")}) + public static class Settings implements PersistentStateComponent { public static final String TYPE_GITHUB = "github"; + public static final String TYPE_GITEA = "gitea"; - private final CopyOnWriteArrayList testServers = new CopyOnWriteArrayList<>(); + public static class StateData { + public List servers = new ArrayList<>(); + } + + public static class ServerState { + public String type = TYPE_GITHUB; + public String name = ""; + public String webUrl = ""; + public String apiUrl = ""; + public String tokenEnvVar = ""; + public boolean enabled = true; + public boolean tokenStored; + + public ServerState() { + // XML serializer + } + + ServerState(final Server server) { + final Server normalized = server.normalized(); + type = normalized.type; + name = normalized.name; + webUrl = normalized.webUrl; + apiUrl = normalized.apiUrl; + tokenEnvVar = normalized.tokenEnvVar; + enabled = normalized.enabled; + tokenStored = normalized.tokenStored; + } + + Server server() { + return new Server(type, name, webUrl, apiUrl, tokenEnvVar, enabled, tokenStored).normalized(); + } + } + + private final StateData state = new StateData(); public static Settings getInstance() { return ApplicationManager.getApplication().getService(Settings.class); } + @Override + public @Nullable StateData getState() { + return state; + } + + @Override + public void loadState(@NotNull final StateData state) { + XmlSerializerUtil.copyBean(state, this.state); + if (this.state.servers == null) { + this.state.servers = new ArrayList<>(); + } + } + + /** + * Returns user-configured remote providers stored by this plugin. + * + * @return normalized configured providers, excluding invalid rows + */ + public List customServers() { + return Optional.ofNullable(state.servers).orElseGet(List::of).stream() + .map(ServerState::server) + .filter(Server::isValid) + .toList(); + } + + /** + * Replaces the user-configured remote providers stored by this plugin. + * + * @param servers provider metadata to persist; invalid rows are ignored + * @return this settings service + */ + public Settings setCustomServers(final List servers) { + state.servers = Optional.ofNullable(servers).orElseGet(List::of).stream() + .map(Server::normalized) + .filter(Server::isValid) + .map(ServerState::new) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + return this; + } + + /** + * Stores or replaces the Gitea token for the supplied provider in the IDE password safe. + * + * @param server provider whose normalized API URL identifies the token + * @param token personal access token + * @return this settings service + */ + public Settings setGiteaToken(final Server server, final String token) { + final Server normalized = Optional.ofNullable(server).orElseGet(Settings::defaultGitHub).normalized(); + if (normalized.isGitea() && hasText(token)) { + PasswordTokens.set(normalized, token); + markGiteaTokenStored(normalized, true); + } + return this; + } + + /** + * Deletes the Gitea token for the supplied provider from the IDE password safe. + * + * @param server provider whose normalized API URL identifies the token + * @return this settings service + */ + public Settings clearGiteaToken(final Server server) { + final Server normalized = Optional.ofNullable(server).orElseGet(Settings::defaultGitHub).normalized(); + if (normalized.isGitea()) { + PasswordTokens.clear(normalized); + markGiteaTokenStored(normalized, false); + } + return this; + } + + /** + * Reads the Gitea token for the supplied provider from the IDE password safe. + * + * @param server provider whose normalized API URL identifies the token + * @return token when one is stored and readable + */ + public Optional giteaToken(final Server server) { + final Server normalized = Optional.ofNullable(server).orElseGet(Settings::defaultGitHub).normalized(); + return normalized.isGitea() ? PasswordTokens.get(normalized) : Optional.empty(); + } + + /** + * Reports whether the supplied Gitea provider has an IDE-stored token. + * + * @param server provider whose normalized API URL identifies the token + * @return true when a token is stored + */ + public boolean hasGiteaToken(final Server server) { + final Server normalized = Optional.ofNullable(server).orElseGet(Settings::defaultGitHub).normalized(); + if (!normalized.isGitea()) { + return false; + } + return normalized.tokenStored || customServers().stream() + .map(Server::normalized) + .anyMatch(candidate -> candidate.key().equals(normalized.key()) && candidate.tokenStored); + } + + private Settings markGiteaTokenStored(final Server server, final boolean stored) { + state.servers.stream() + .filter(state -> state.server().key().equals(server.key())) + .forEach(state -> state.tokenStored = stored); + return this; + } + public List enabledServers() { final Map result = new LinkedHashMap<>(); - testServers.stream() + customServers().stream() .map(Server::normalized) + .filter(Server::isEnabled) .filter(Server::isValid) .forEach(server -> result.put(server.key(), server)); jetBrainsGithubServers().forEach(server -> result.putIfAbsent(server.key(), server)); @@ -393,14 +545,6 @@ public List enabledServers() { 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); } @@ -430,6 +574,65 @@ private static List jetBrainsGithubServers() { private static int accountOrder(final GithubAccount account) { return account.getServer().isGithubDotCom() ? 0 : 1; } + + private static class PasswordTokens { + + private static final String SERVICE_PREFIX = "GitHub Workflow Gitea "; + + private static Optional get(final Server server) { + try { + return Optional.ofNullable(PasswordSafe.getInstance().get(attributes(server))) + .map(Credentials::getPasswordAsString) + .filter(RemoteActionProviders::hasText); + } catch (final RuntimeException ignored) { + return Optional.empty(); + } + } + + private static void set(final Server server, final String token) { + write(() -> setNow(server, token)); + } + + private static void clear(final Server server) { + write(() -> clearNow(server)); + } + + private static void write(final Runnable operation) { + final Application application = ApplicationManager.getApplication(); + if (application != null && application.isDispatchThread()) { + try { + application.executeOnPooledThread(operation).get(); + } catch (final InterruptedException exception) { + Thread.currentThread().interrupt(); + LOG.warn("Gitea token write interrupted", exception); + } catch (final ExecutionException exception) { + LOG.warn("Gitea token write failed", exception); + } + return; + } + operation.run(); + } + + private static void setNow(final Server server, final String token) { + try { + PasswordSafe.getInstance().set(attributes(server), new Credentials(server.name, token)); + } catch (final RuntimeException exception) { + LOG.warn("Gitea token storage failed for [" + server.apiUrl + "]", exception); + } + } + + private static void clearNow(final Server server) { + try { + PasswordSafe.getInstance().set(attributes(server), null); + } catch (final RuntimeException exception) { + LOG.warn("Gitea token deletion failed for [" + server.apiUrl + "]", exception); + } + } + + private static CredentialAttributes attributes(final Server server) { + return new CredentialAttributes(SERVICE_PREFIX + server.normalized().apiUrl); + } + } } public static class Server { @@ -439,20 +642,99 @@ public static class Server { public final String apiUrl; public final String tokenEnvVar; public final boolean enabled; + public final boolean tokenStored; + + public Server( + final String name, + final String webUrl, + final String apiUrl, + final String tokenEnvVar, + final boolean enabled + ) { + this(Settings.TYPE_GITHUB, name, webUrl, apiUrl, tokenEnvVar, enabled); + } public Server( + final String type, final String name, final String webUrl, final String apiUrl, final String tokenEnvVar, final boolean enabled ) { - this.type = Settings.TYPE_GITHUB; + this(type, name, webUrl, apiUrl, tokenEnvVar, enabled, false); + } + + public Server( + final String type, + final String name, + final String webUrl, + final String apiUrl, + final String tokenEnvVar, + final boolean enabled, + final boolean tokenStored + ) { + this.type = Optional.ofNullable(type).map(String::trim).filter(RemoteActionProviders::hasText).orElse(Settings.TYPE_GITHUB); this.name = name; this.webUrl = webUrl; this.apiUrl = apiUrl; this.tokenEnvVar = tokenEnvVar; this.enabled = enabled; + this.tokenStored = tokenStored; + } + + /** + * Creates a configured Gitea remote provider. Gitea uses GitHub-compatible repository endpoints under + * {@code /api/v1}, but personal access tokens use {@code Authorization: token ...}. + * + * @param name readable server name shown in diagnostics + * @param webUrl browser URL of the Gitea instance + * @param apiUrl REST API URL, usually {@code /api/v1} + * @param tokenEnvVar optional environment variable containing a Gitea token + * @param enabled whether the provider should be used + * @return a Gitea server configuration + */ + public static Server gitea( + final String name, + final String webUrl, + final String apiUrl, + final String tokenEnvVar, + final boolean enabled + ) { + return gitea(name, webUrl, apiUrl, tokenEnvVar, enabled, false); + } + + public static Server gitea( + final String name, + final String webUrl, + final String apiUrl, + final String tokenEnvVar, + final boolean enabled, + final boolean tokenStored + ) { + return new Server(Settings.TYPE_GITEA, name, webUrl, apiUrl, tokenEnvVar, enabled, tokenStored); + } + + /** + * Infers the remote provider for a workflow-run request. Gitea is detected from its workflow home, + * {@code /api/v1} API base, or a Gitea-named token variable. + * + * @param apiUrl REST API URL configured for the run + * @param workflowPath workflow file path being dispatched + * @param tokenEnvVar optional token environment variable configured for the run + * @return normalized remote provider configuration + */ + public static Server fromWorkflowRun( + final String apiUrl, + final String workflowPath, + final String tokenEnvVar + ) { + final String normalizedApiUrl = trimTrailingSlash(apiUrl); + final String webUrl = webUrlFromApiUrl(normalizedApiUrl); + if (looksLikeGitea(normalizedApiUrl, workflowPath, tokenEnvVar)) { + return gitea("Gitea", webUrl, normalizedApiUrl, tokenEnvVar, true).normalized(); + } + return new Server("GitHub", webUrl, normalizedApiUrl, tokenEnvVar, true).normalized(); } public boolean isEnabled() { @@ -460,28 +742,69 @@ public boolean isEnabled() { } public boolean isValid() { - return isEnabled() && hasText(webUrl) && hasText(apiUrl); + return hasText(webUrl) && hasText(apiUrl); + } + + /** + * Reports whether this provider uses Gitea-compatible request behavior. + * + * @return {@code true} for Gitea providers + */ + public boolean isGitea() { + return Settings.TYPE_GITEA.equalsIgnoreCase(type); } public String authorizationHeader() { - return Optional.ofNullable(tokenEnvVar) - .filter(RemoteActionProviders::hasText) - .map(System::getenv) - .filter(RemoteActionProviders::hasText) - .map(token -> "Bearer " + token) + return Settings.getInstance().giteaToken(this) + .or(() -> Optional.ofNullable(tokenEnvVar) + .filter(RemoteActionProviders::hasText) + .map(System::getenv) + .filter(RemoteActionProviders::hasText)) + .map(token -> authorizationPrefix() + token) .orElse(""); } public Server normalized() { return new Server( + hasText(type) ? type.trim().toLowerCase() : Settings.TYPE_GITHUB, hasText(name) ? name.trim() : webUrl, trimTrailingSlash(webUrl), trimTrailingSlash(apiUrl), Optional.ofNullable(tokenEnvVar).map(String::trim).orElse(""), - enabled + enabled, + tokenStored ); } + @Override + public boolean equals(final Object object) { + if (this == object) { + return true; + } + if (!(object instanceof Server server)) { + return false; + } + final Server left = normalized(); + final Server right = server.normalized(); + return left.enabled == right.enabled + && left.tokenStored == right.tokenStored + && Objects.equals(left.type, right.type) + && Objects.equals(left.name, right.name) + && Objects.equals(left.webUrl, right.webUrl) + && Objects.equals(left.apiUrl, right.apiUrl) + && Objects.equals(left.tokenEnvVar, right.tokenEnvVar); + } + + @Override + public int hashCode() { + final Server normalized = normalized(); + return Objects.hash(normalized.type, normalized.name, normalized.webUrl, normalized.apiUrl, normalized.tokenEnvVar, normalized.enabled, normalized.tokenStored); + } + + private String authorizationPrefix() { + return isGitea() ? "token " : "Bearer "; + } + private String key() { final Server normalized = normalized(); return normalized.type + "|" + normalized.webUrl + "|" + normalized.apiUrl; @@ -490,7 +813,8 @@ private String key() { public static class Authorizations { - private static final List DEFAULT_ENV_TOKENS = List.of("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT"); + private static final List DEFAULT_GITHUB_ENV_TOKENS = List.of("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT"); + private static final List DEFAULT_GITEA_ENV_TOKENS = List.of("GITEA_TOKEN", "GITEA_PAT"); public static List forApiUrl(final String apiUrl, final String tokenEnvVar, final Project project) { return forApiUrl(apiUrl, tokenEnvVar, project, System.getenv()); @@ -501,20 +825,128 @@ public static List forApiUrl( final String tokenEnvVar, final Project project, final Map environment + ) { + return forGithubApiUrl(apiUrl, tokenEnvVar, project, environment); + } + + /** + * Returns authorizations for a workflow-run request. Gitea run requests use Gitea token variables with + * {@code token ...}; GitHub requests keep JetBrains GitHub account and {@code Bearer ...} token priority. + * + * @param apiUrl REST API URL configured for the run + * @param workflowPath workflow file path being dispatched + * @param tokenEnvVar optional token environment variable configured for the run + * @param project project used when JetBrains GitHub tokens need to be requested + * @return ordered authorizations ending with anonymous access + */ + public static List forWorkflowRun( + final String apiUrl, + final String workflowPath, + final String tokenEnvVar, + final Project project + ) { + return forWorkflowRun(apiUrl, workflowPath, tokenEnvVar, project, System.getenv()); + } + + /** + * Returns authorizations for a workflow-run request using the supplied environment map. + * + * @param apiUrl REST API URL configured for the run + * @param workflowPath workflow file path being dispatched + * @param tokenEnvVar optional token environment variable configured for the run + * @param project project used when JetBrains GitHub tokens need to be requested + * @param environment environment variables used for explicit and default tokens + * @return ordered authorizations ending with anonymous access + */ + public static List forWorkflowRun( + final String apiUrl, + final String workflowPath, + final String tokenEnvVar, + final Project project, + final Map environment + ) { + return forServer(Server.fromWorkflowRun(apiUrl, workflowPath, tokenEnvVar), project, environment); + } + + public static List forServer(final Server server, final Project project) { + return forServer(server, project, System.getenv()); + } + + /** + * Returns authorizations for a configured remote provider. GitHub providers reuse JetBrains GitHub accounts + * and GitHub token environment variables. Gitea providers use only configured or Gitea-specific environment + * tokens because JetBrains has no compatible Gitea account store here. + * + * @param server configured remote provider + * @param project project used when JetBrains GitHub tokens need to be requested + * @param environment environment variables used for explicit and default tokens + * @return ordered authorizations ending with anonymous access + */ + public static List forServer( + final Server server, + final Project project, + final Map environment + ) { + final Server normalized = Optional.ofNullable(server).orElseGet(Settings::defaultGitHub).normalized(); + if (normalized.isGitea()) { + final LinkedHashMap result = new LinkedHashMap<>(); + giteaAccountAuthorizations(normalized) + .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); + envAuthorizations(normalized.tokenEnvVar, environment, DEFAULT_GITEA_ENV_TOKENS, "token ") + .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); + result.putIfAbsent(Authorization.anonymous().key(), Authorization.anonymous()); + return List.copyOf(result.values()); + } + return forGithubApiUrl(normalized.apiUrl, normalized.tokenEnvVar, project, environment); + } + + private static List forGithubApiUrl( + 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) + envAuthorizations(tokenEnvVar, environment, DEFAULT_GITHUB_ENV_TOKENS, "Bearer ") .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); result.putIfAbsent(Authorization.anonymous().key(), Authorization.anonymous()); return List.copyOf(result.values()); } + public static String settingsHint(final Server server) { + final Server normalized = Optional.ofNullable(server).orElseGet(Settings::defaultGitHub).normalized(); + return normalized.isGitea() + ? GitHubWorkflowBundle.message("workflow.run.auth.settings.gitea") + : GitHubWorkflowBundle.message("workflow.run.auth.settings.github"); + } + public static String settingsHint() { - return GitHubWorkflowBundle.message("workflow.run.auth.settings"); + return GitHubWorkflowBundle.message("workflow.run.auth.settings.github"); + } + + private static List giteaAccountAuthorizations(final Server server) { + final Settings settings = Settings.getInstance(); + return settings.enabledServers().stream() + .filter(Server::isGitea) + .sorted(Comparator + .comparingInt((Server candidate) -> giteaAccountPriority(candidate, server)) + .thenComparing(candidate -> candidate.apiUrl) + .thenComparing(candidate -> candidate.name)) + .map(candidate -> settings.giteaToken(candidate) + .map(token -> new Authorization(candidate.name, "token " + token))) + .flatMap(Optional::stream) + .toList(); + } + + private static int giteaAccountPriority(final Server candidate, final Server server) { + if (candidate.normalized().apiUrl.equals(server.normalized().apiUrl)) { + return 0; + } + return sameHost(candidate.apiUrl, server.apiUrl) ? 1 : 2; } private static List orderedAccountsFor(final String apiUrl) { @@ -551,24 +983,34 @@ private static Optional authorization(final GithubAccount account } } - private static List envAuthorizations(final String tokenEnvVar, final Map environment) { + private static List envAuthorizations( + final String tokenEnvVar, + final Map environment, + final List defaultTokenEnvVars, + final String authorizationPrefix + ) { final LinkedHashMap result = new LinkedHashMap<>(); - envAuthorization(tokenEnvVar, environment).ifPresent(authorization -> result.putIfAbsent(authorization.key(), authorization)); - DEFAULT_ENV_TOKENS.stream() + final Map values = Optional.ofNullable(environment).orElseGet(Map::of); + envAuthorization(tokenEnvVar, values, authorizationPrefix).ifPresent(authorization -> result.putIfAbsent(authorization.key(), authorization)); + defaultTokenEnvVars.stream() .filter(name -> !name.equals(tokenEnvVar)) - .map(name -> envAuthorization(name, environment)) + .map(name -> envAuthorization(name, values, authorizationPrefix)) .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) { + private static Optional envAuthorization( + final String tokenEnvVar, + final Map environment, + final String authorizationPrefix + ) { 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))); + .map(token -> new Authorization(name, authorizationPrefix + token))); } private static Project project(final Project project) { @@ -617,6 +1059,26 @@ private static String trimTrailingSlash(final String value) { return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; } + private static String webUrlFromApiUrl(final String apiUrl) { + final String trimmed = trimTrailingSlash(apiUrl); + if (trimmed.equals("https://api.github.com")) { + return "https://github.com"; + } + return trimmed.replaceFirst("/api/v(?:1|3)$", ""); + } + + private static boolean looksLikeGitea( + final String apiUrl, + final String workflowPath, + final String tokenEnvVar + ) { + final String normalizedWorkflowPath = Optional.ofNullable(workflowPath).orElse("").replace('\\', '/'); + final String normalizedTokenEnvVar = Optional.ofNullable(tokenEnvVar).orElse("").toUpperCase(); + return normalizedWorkflowPath.startsWith(".gitea/workflows/") + || trimTrailingSlash(apiUrl).endsWith("/api/v1") + || normalizedTokenEnvVar.contains("GITEA"); + } + private static boolean hasText(final String value) { return value != null && !value.isBlank(); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java index 397ac53..b912e63 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java @@ -36,7 +36,10 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -45,7 +48,6 @@ 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; @@ -69,11 +71,21 @@ 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)); + .build()), request -> RemoteActionProviders.Authorizations.forWorkflowRun( + request.apiUrl(), + request.workflowPath(), + request.tokenEnvVar(), + project + )); } WorkflowRun(final HttpTransport transport) { - this(transport, request -> RemoteActionProviders.Authorizations.forApiUrl(request.apiUrl(), request.tokenEnvVar(), null)); + this(transport, request -> RemoteActionProviders.Authorizations.forWorkflowRun( + request.apiUrl(), + request.workflowPath(), + request.tokenEnvVar(), + null + )); } WorkflowRun(final HttpTransport transport, final AuthorizationProvider authorizationProvider) { @@ -85,7 +97,7 @@ public DispatchResult dispatch(final Request request) throws IOException, Interr final HttpResponse response = send( request, "POST", - workflowUrl(request) + "/dispatches", + dispatchUrl(request), dispatchBody(request), "GitHub workflow dispatch" ); @@ -165,19 +177,45 @@ public DeleteResult delete(final Request request, final long runId) throws IOExc return new DeleteResult(response.statusCode(), accepted(response)); } + /** + * Finds the most likely run created by a workflow dispatch when the dispatch response did not include a run id. + * + * @param request workflow repository and branch context + * @return the closest matching workflow dispatch run, or an empty result when no safe candidate exists + * @throws IOException when the remote API rejects the request or the network call fails + * @throws InterruptedException when the IDE cancels the remote call + */ public Optional latestRun(final Request request) throws IOException, InterruptedException { + return latestRun(request, Instant.now()); + } + + /** + * Finds the most likely run created near one dispatch timestamp. + * + * @param request workflow repository and branch context + * @param dispatchTime local timestamp captured immediately before dispatching the workflow + * @return the closest same-workflow run near the dispatch timestamp, or an empty result when no candidate fits + * @throws IOException when the remote API rejects the request or the network call fails + * @throws InterruptedException when the IDE cancels the remote call + */ + public Optional latestRun(final Request request, final Instant dispatchTime) throws IOException, InterruptedException { final HttpResponse response = send( request, "GET", - workflowUrl(request) + "/runs?branch=" + encode(request.ref()) + "&event=workflow_dispatch&per_page=1", + latestRunsUrl(request), "", "GitHub workflow run discovery" ); final JsonObject json = parseObject(response.body()); + final Instant baseTime = Optional.ofNullable(dispatchTime).orElse(Instant.EPOCH); + final Instant earliest = baseTime.minusSeconds(30); return objects(json, "workflow_runs") - .findFirst() - .map(run -> runStatus(run, -1L)) - .filter(run -> run.runId() >= 0); + .map(run -> runCandidate(request, run)) + .filter(candidate -> candidate.status().runId() >= 0) + .filter(RunCandidate::workflowMatches) + .filter(candidate -> candidate.timestamp().map(timestamp -> !timestamp.isBefore(earliest)).orElse(true)) + .min(runCandidateComparator(baseTime)) + .map(RunCandidate::status); } public String logs(final Request request, final long runId) throws IOException, InterruptedException { @@ -298,7 +336,7 @@ private HttpResponse sendWithAuthorizations( final String body, final String operation, final ResponseSender sender, - final BiFunction, WorkflowRunHttpException> failureFactory, + final FailureFactory failureFactory, final Function, String> bodyText ) throws IOException, InterruptedException { WorkflowRunHttpException lastFailure = null; @@ -315,7 +353,7 @@ private HttpResponse sendWithAuthorizations( } return response; } - lastFailure = failureFactory.apply(operation, response); + lastFailure = failureFactory.failure(workflow, operation, response); if (authorization.authenticated() && rateLimitExceeded(response.statusCode(), response.headers(), bodyText.apply(response))) { authenticatedRateLimitFailure = true; } @@ -363,9 +401,13 @@ private static HttpRequest request( ) { 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 (server(workflow).isGitea()) { + builder.header("Accept", "application/json"); + } else { + builder.header("Accept", "application/vnd.github+json"); + builder.header("X-GitHub-Api-Version", API_VERSION); + } if (authorization.authenticated()) { builder.header("Authorization", authorization.authorizationHeader()); } @@ -379,31 +421,38 @@ private static HttpRequest request( 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 failure(final Request request, final String operation, final HttpResponse response) { + return failure(request, operation, response.statusCode(), response.headers(), response.body()); } - private static WorkflowRunHttpException failureBytes(final String operation, final HttpResponse response) { + private static WorkflowRunHttpException failureBytes(final Request request, 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); + return failure(request, operation, response.statusCode(), response.headers(), body); } private static WorkflowRunHttpException failure( + final Request request, final String operation, final int statusCode, final HttpHeaders headers, final String body ) { final boolean accountActionRecommended = needsAccountAction(statusCode, headers, body); + final RemoteActionProviders.Server server = RemoteActionProviders.Server.fromWorkflowRun( + request.apiUrl(), + request.workflowPath(), + request.tokenEnvVar() + ); final String hint = accountActionRecommended - ? "\nAdd or refresh GitHub accounts in " + RemoteActionProviders.Authorizations.settingsHint() + "." + ? "\n" + GitHubWorkflowBundle.message("workflow.run.auth.add", RemoteActionProviders.Authorizations.settingsHint(server)) : ""; final String summary = responseSummary(statusCode, headers, body); return new WorkflowRunHttpException( operation + " failed with HTTP " + statusCode + (summary.isEmpty() ? "" : ": " + summary) + hint, statusCode, body, - accountActionRecommended + accountActionRecommended, + server.isGitea() ? "github.workflow.gitea.settings" : "GitHub" ); } @@ -473,6 +522,20 @@ 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 dispatchUrl(final Request request) { + final String url = workflowUrl(request) + "/dispatches"; + return server(request).isGitea() ? url + "?return_run_details=true" : url; + } + + private static String latestRunsUrl(final Request request) { + final RemoteActionProviders.Server server = server(request); + final String baseUrl = server.isGitea() + ? request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/runs" + : workflowUrl(request) + "/runs"; + final String pageLimit = server.isGitea() ? "limit=20" : "per_page=20"; + return baseUrl + "?branch=" + encode(request.ref()) + "&event=workflow_dispatch&" + pageLimit; + } + private static String authorizationCacheKey(final Request request) { return Optional.ofNullable(request.apiUrl()).orElse("") + "|" + Optional.ofNullable(request.tokenEnvVar()).orElse(""); } @@ -487,6 +550,10 @@ private static String workflowId(final String workflowPath) { return slash < 0 ? normalized : normalized.substring(slash + 1); } + private static RemoteActionProviders.Server server(final Request request) { + return RemoteActionProviders.Server.fromWorkflowRun(request.apiUrl(), request.workflowPath(), request.tokenEnvVar()); + } + private static String dispatchBody(final Request request) { final StringJoiner inputs = new StringJoiner(","); request.inputs().entrySet().stream() @@ -527,6 +594,72 @@ private static RunStatus runStatus(final JsonObject object, final long fallbackR ); } + private static RunCandidate runCandidate(final Request request, final JsonObject object) { + return new RunCandidate( + runStatus(object, -1L), + runTimestamp(object), + workflowMatches(request, object) + ); + } + + private static Comparator runCandidateComparator(final Instant dispatchTime) { + return Comparator + .comparingInt((RunCandidate candidate) -> candidate.timestamp().isPresent() ? 0 : 1) + .thenComparingLong(candidate -> candidate.timestamp() + .map(timestamp -> instantDistanceMillis(timestamp, dispatchTime)) + .orElse(Long.MAX_VALUE)); + } + + private static long instantDistanceMillis(final Instant left, final Instant right) { + try { + return Math.abs(Duration.between(left, right).toMillis()); + } catch (final ArithmeticException ignored) { + return Long.MAX_VALUE; + } + } + + private static Optional runTimestamp(final JsonObject object) { + return Stream.of("created_at", "run_started_at", "started_at", "updated_at") + .flatMap(name -> stringValue(object, name).stream()) + .flatMap(value -> parseInstant(value).stream()) + .filter(timestamp -> timestamp.isAfter(Instant.parse("2000-01-01T00:00:00Z"))) + .findFirst(); + } + + private static Optional parseInstant(final String value) { + try { + return Optional.of(Instant.parse(value)); + } catch (final DateTimeParseException ignored) { + return Optional.empty(); + } + } + + private static boolean workflowMatches(final Request request, final JsonObject object) { + if (!server(request).isGitea()) { + return true; + } + return Stream.of("path", "workflow_path", "workflow_file_path") + .flatMap(name -> stringValue(object, name).stream()) + .findFirst() + .map(path -> sameWorkflowPath(request.workflowPath(), path)) + .orElse(true); + } + + private static boolean sameWorkflowPath(final String expected, final String actual) { + final String normalizedExpected = normalizeWorkflowPath(expected); + final String normalizedActual = normalizeWorkflowPath(actual); + return normalizedExpected.equals(normalizedActual) + || workflowId(normalizedExpected).equals(workflowId(normalizedActual)); + } + + private static String normalizeWorkflowPath(final String value) { + String normalized = Optional.ofNullable(value).orElse("").replace('\\', '/').strip(); + while (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + return normalized; + } + private static JobStatus jobStatus(final JsonObject object) { return new JobStatus( longValue(object, "id").orElse(-1L), @@ -605,6 +738,10 @@ private interface ResponseSender { HttpResponse send(HttpRequest request) throws IOException, InterruptedException; } + private interface FailureFactory { + WorkflowRunHttpException failure(Request request, String operation, HttpResponse response); + } + private record JdkHttpTransport(HttpClient client) implements HttpTransport { @Override public HttpResponse send(final HttpRequest request) throws IOException, InterruptedException { @@ -999,6 +1136,9 @@ public boolean completed() { } } + private record RunCandidate(RunStatus status, Optional timestamp, boolean workflowMatches) { + } + public record CancelResult(int statusCode, boolean accepted) { } @@ -1019,17 +1159,20 @@ public static class WorkflowRunHttpException extends IOException { private final int statusCode; private final String body; private final boolean accountActionRecommended; + private final String settingsId; public WorkflowRunHttpException( final String message, final int statusCode, final String body, - final boolean accountActionRecommended + final boolean accountActionRecommended, + final String settingsId ) { super(message); this.statusCode = statusCode; this.body = body; this.accountActionRecommended = accountActionRecommended; + this.settingsId = settingsId; } public int statusCode() { @@ -1043,5 +1186,9 @@ public String body() { public boolean accountActionRecommended() { return accountActionRecommended; } + + public String settingsId() { + return settingsId; + } } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandler.java b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandler.java index 3d752ef..b377af2 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandler.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandler.java @@ -23,6 +23,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; @@ -157,7 +158,7 @@ private void runWorkflow() { terminate(runFromTrigger()); } catch (final IOException | RuntimeException exception) { if (exception instanceof WorkflowRun.WorkflowRunHttpException httpException && httpException.accountActionRecommended()) { - notifyAuthenticationHelp(); + notifyAuthenticationHelp(httpException.settingsId()); } stderr(exception.getMessage() + "\n"); terminate(1, "failure"); @@ -180,10 +181,11 @@ private RunOutcome runFromTrigger() throws IOException, InterruptedException { private long dispatchFromTrigger() throws IOException, InterruptedException { stdout(dispatchMessage() + "\n"); - return resolveRunId(client.dispatch(request)); + final Instant dispatchTime = Instant.now(); + return resolveRunId(client.dispatch(request), dispatchTime); } - private long resolveRunId(final WorkflowRun.DispatchResult dispatch) throws IOException, InterruptedException { + private long resolveRunId(final WorkflowRun.DispatchResult dispatch, final Instant dispatchTime) throws IOException, InterruptedException { if (hasText(dispatch.htmlUrl())) { stdout(GitHubWorkflowBundle.message("workflow.run.link", dispatch.htmlUrl()) + "\n"); } @@ -192,7 +194,7 @@ private long resolveRunId(final WorkflowRun.DispatchResult dispatch) throws IOEx } stdout(GitHubWorkflowBundle.message("workflow.run.discovery") + "\n"); for (int attempt = 0; attempt < 12 && !stopping.get(); attempt++) { - final var latest = client.latestRun(request); + final var latest = client.latestRun(request, dispatchTime); if (latest.isPresent()) { final WorkflowRun.RunStatus run = latest.get(); if (hasText(run.htmlUrl())) { @@ -341,9 +343,9 @@ private String dispatchMessage() { } private String workflowUrl() { - final String webUrl = request.apiUrl().equals("https://api.github.com") - ? "https://github.com" - : request.apiUrl().replaceFirst("/api/v3/?$", ""); + final String webUrl = RemoteActionProviders.Server + .fromWorkflowRun(request.apiUrl(), request.workflowPath(), request.tokenEnvVar()) + .webUrl; return " (" + webUrl + "/" + request.owner() + "/" + request.repo() + "/blob/" + request.ref() + "/" + request.workflowPath() + ")"; } @@ -530,16 +532,21 @@ private void terminate(final RunOutcome outcome) { terminate(outcome.exitCode(), outcome.conclusion()); } - private void notifyAuthenticationHelp() { + private void notifyAuthenticationHelp(final String settingsId) { final var notification = NotificationGroupManager.getInstance() .getNotificationGroup("GitHub Workflow") .createNotification( - GitHubWorkflowBundle.message("workflow.run.notification.auth", RemoteActionProviders.Authorizations.settingsHint()), + GitHubWorkflowBundle.message( + "workflow.run.notification.auth", + "github.workflow.gitea.settings".equals(settingsId) + ? GitHubWorkflowBundle.message("workflow.run.auth.settings.gitea") + : GitHubWorkflowBundle.message("workflow.run.auth.settings.github") + ), NotificationType.WARNING ); notification.addAction(NotificationAction.createSimple(GitHubWorkflowBundle.message("workflow.run.notification.openSettings"), () -> ApplicationManager.getApplication().invokeLater(() -> - ShowSettingsUtil.getInstance().showSettingsDialog(project, "GitHub")))); + ShowSettingsUtil.getInstance().showSettingsDialog(project, settingsId)))); notification.notify(project); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/settings/GitHubWorkflowSettingsConfigurable.java b/src/main/java/com/github/yunabraska/githubworkflow/settings/GitHubWorkflowSettingsConfigurable.java index bcc1753..e97942a 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/settings/GitHubWorkflowSettingsConfigurable.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/settings/GitHubWorkflowSettingsConfigurable.java @@ -5,7 +5,6 @@ import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.ide.BrowserUtil; -import com.intellij.openapi.options.ConfigurationException; import com.intellij.openapi.options.SearchableConfigurable; import com.intellij.openapi.ui.Messages; import com.intellij.ui.table.JBTable; @@ -22,6 +21,7 @@ import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTable; +import javax.swing.border.TitledBorder; import javax.swing.table.DefaultTableModel; import java.awt.BorderLayout; import java.awt.FlowLayout; @@ -33,6 +33,7 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -74,8 +75,19 @@ public class GitHubWorkflowSettingsConfigurable implements SearchableConfigurabl private final JComboBox language = new JComboBox<>(LOCALES.toArray(LocaleOption[]::new)); private final DefaultTableModel tableModel = new DefaultTableModel(); private final JTable table = new JBTable(tableModel); + private final JLabel languageLabel = new JLabel(); + private final JButton support = new JButton(); private final JLabel summary = new JLabel(); + private final List buttons = new ArrayList<>(); private @Nullable JPanel panel; + private @Nullable TitledBorder cacheBorder; + + /** + * Creates the settings page and wires the support button action. + */ + public GitHubWorkflowSettingsConfigurable() { + support.addActionListener(event -> BrowserUtil.browse(SUPPORT_URL)); + } @Override public @NotNull String getId() { @@ -89,6 +101,7 @@ public class GitHubWorkflowSettingsConfigurable implements SearchableConfigurabl @Override public @Nullable JComponent createComponent() { + buttons.clear(); panel = new JPanel(new BorderLayout(8, 8)); panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); panel.add(topPanel(), BorderLayout.NORTH); @@ -104,9 +117,10 @@ public boolean isModified() { } @Override - public void apply() throws ConfigurationException { + public void apply() { final LocaleOption option = (LocaleOption) language.getSelectedItem(); settings.languageTag(option == null ? GitHubWorkflowBundle.Settings.SYSTEM_LANGUAGE : option.tag()); + refreshTexts(); reloadTable(); GitHubActionCache.triggerSyntaxHighlightingForActiveFiles(); } @@ -114,6 +128,7 @@ public void apply() throws ConfigurationException { @Override public void reset() { selectLanguage(settings.languageTag()); + refreshTexts(); reloadTable(); } @@ -129,7 +144,7 @@ private JPanel topPanel() { label.gridy = 0; label.anchor = GridBagConstraints.WEST; label.insets = new Insets(0, 0, 0, 8); - result.add(new JLabel(GitHubWorkflowBundle.message("settings.language.label")), label); + result.add(languageLabel, label); final GridBagConstraints combo = new GridBagConstraints(); combo.gridx = 1; @@ -138,9 +153,6 @@ private JPanel topPanel() { combo.fill = GridBagConstraints.HORIZONTAL; result.add(language, combo); - final JButton support = new JButton(randomSupportLine()); - support.setToolTipText(GitHubWorkflowBundle.message("settings.support.tooltip")); - support.addActionListener(event -> BrowserUtil.browse(SUPPORT_URL)); final GridBagConstraints supportConstraints = new GridBagConstraints(); supportConstraints.gridx = 2; supportConstraints.gridy = 0; @@ -150,17 +162,12 @@ private JPanel topPanel() { } private JPanel cachePanel() { - tableModel.setColumnIdentifiers(new Object[]{ - GitHubWorkflowBundle.message("settings.cache.column.key"), - GitHubWorkflowBundle.message("settings.cache.column.name"), - GitHubWorkflowBundle.message("settings.cache.column.kind"), - GitHubWorkflowBundle.message("settings.cache.column.state"), - GitHubWorkflowBundle.message("settings.cache.column.expires") - }); + setCacheColumnHeaders(); table.setSelectionMode(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); final JPanel result = new JPanel(new BorderLayout(4, 4)); - result.setBorder(BorderFactory.createTitledBorder(GitHubWorkflowBundle.message("settings.cache.title"))); + cacheBorder = BorderFactory.createTitledBorder(GitHubWorkflowBundle.message("settings.cache.title")); + result.setBorder(cacheBorder); result.add(summary, BorderLayout.NORTH); result.add(new JScrollPane(table), BorderLayout.CENTER); result.add(cacheButtons(), BorderLayout.SOUTH); @@ -180,6 +187,7 @@ private JPanel cacheButtons() { private void addButton(final JPanel panel, final String key, final Runnable action) { final JButton button = new JButton(GitHubWorkflowBundle.message(key)); button.addActionListener(event -> action.run()); + buttons.add(new LocalizedButton(button, key)); panel.add(button); } @@ -206,6 +214,31 @@ private void reloadTable() { summary.setToolTipText(null); } + private void setCacheColumnHeaders() { + tableModel.setColumnIdentifiers(new Object[]{ + GitHubWorkflowBundle.message("settings.cache.column.key"), + GitHubWorkflowBundle.message("settings.cache.column.name"), + GitHubWorkflowBundle.message("settings.cache.column.kind"), + GitHubWorkflowBundle.message("settings.cache.column.state"), + GitHubWorkflowBundle.message("settings.cache.column.expires") + }); + } + + private void refreshTexts() { + languageLabel.setText(GitHubWorkflowBundle.message("settings.language.label")); + support.setText(randomSupportLine()); + support.setToolTipText(GitHubWorkflowBundle.message("settings.support.tooltip")); + buttons.forEach(button -> button.component().setText(GitHubWorkflowBundle.message(button.key()))); + setCacheColumnHeaders(); + if (cacheBorder != null) { + cacheBorder.setTitle(GitHubWorkflowBundle.message("settings.cache.title")); + } + if (panel != null) { + panel.revalidate(); + panel.repaint(); + } + } + private String stateText(final GitHubActionCache.CacheEntry entry) { if (entry.suppressed()) { return GitHubWorkflowBundle.message("settings.cache.state.suppressed"); @@ -296,4 +329,7 @@ public String toString() { return bundleKey ? GitHubWorkflowBundle.message(label) : label; } } + + private record LocalizedButton(JButton component, String key) { + } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/settings/GiteaSettingsConfigurable.java b/src/main/java/com/github/yunabraska/githubworkflow/settings/GiteaSettingsConfigurable.java new file mode 100644 index 0000000..c610e22 --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/settings/GiteaSettingsConfigurable.java @@ -0,0 +1,365 @@ +package com.github.yunabraska.githubworkflow.settings; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.intellij.openapi.options.SearchableConfigurable; +import com.intellij.openapi.ui.Messages; +import com.intellij.ui.table.JBTable; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.border.TitledBorder; +import javax.swing.table.DefaultTableModel; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Settings UI for Gitea accounts used by workflow metadata and run requests. + */ +public class GiteaSettingsConfigurable implements SearchableConfigurable { + + private static final String STORED_TOKEN = "********"; + private static final int ENABLED = 0; + private static final int NAME = 1; + private static final int WEB_URL = 2; + private static final int API_URL = 3; + private static final int TOKEN_ENV = 4; + private static final int TOKEN = 5; + private static final int ROW_ID = 6; + + private final RemoteActionProviders.Settings settings = RemoteActionProviders.Settings.getInstance(); + private final DefaultTableModel model = new DefaultTableModel() { + @Override + public Class getColumnClass(final int columnIndex) { + return columnIndex == ENABLED ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(final int row, final int column) { + return column != TOKEN && column != ROW_ID; + } + }; + private final JTable table = new JBTable(model); + private final Map pendingTokens = new HashMap<>(); + private final Set clearedTokens = new HashSet<>(); + private final Set storedTokenRows = new HashSet<>(); + private final List buttons = new ArrayList<>(); + private int nextRowSequence = 1; + private @Nullable JPanel panel; + private @Nullable TitledBorder border; + + /** + * Returns the stable settings identifier used by IntelliJ Settings search. + * + * @return the Gitea settings configurable id + */ + @Override + public @NotNull String getId() { + return "github.workflow.gitea.settings"; + } + + /** + * Returns the localized Gitea settings page title. + * + * @return localized display name + */ + @Override + public @Nls String getDisplayName() { + return GitHubWorkflowBundle.message("settings.gitea.displayName"); + } + + /** + * Builds the Gitea account editor. + * + * @return settings component + */ + @Override + public @Nullable JComponent createComponent() { + buttons.clear(); + panel = new JPanel(new BorderLayout(4, 4)); + panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + panel.add(accountsPanel(), BorderLayout.CENTER); + reset(); + return panel; + } + + /** + * Reports whether table rows or pending token edits differ from persisted settings. + * + * @return {@code true} when user edits are pending + */ + @Override + public boolean isModified() { + return invalidRowCount() > 0 + || !Objects.equals(serversFromTable(), settings.customServers().stream() + .filter(RemoteActionProviders.Server::isGitea) + .toList()) + || tokenModified(); + } + + /** + * Stores Gitea account rows and tokens in IDE settings and Password Safe. + */ + @Override + public void apply() { + if (invalidRowCount() > 0) { + Messages.showErrorDialog(panel, GitHubWorkflowBundle.message("settings.gitea.invalidRows"), getDisplayName()); + return; + } + saveRows(); + refreshTexts(); + GitHubActionCache.triggerSyntaxHighlightingForActiveFiles(); + } + + /** + * Reloads persisted Gitea account rows and refreshes localized labels. + */ + @Override + public void reset() { + reloadTable(); + refreshTexts(); + } + + /** + * Releases Swing component references. + */ + @Override + public void disposeUIResources() { + panel = null; + border = null; + } + + private JPanel accountsPanel() { + setColumnHeaders(); + table.setSelectionMode(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + + final JPanel result = new JPanel(new BorderLayout(4, 4)); + border = BorderFactory.createTitledBorder(GitHubWorkflowBundle.message("settings.gitea.title")); + result.setBorder(border); + result.add(new JScrollPane(table), BorderLayout.CENTER); + result.add(buttonsPanel(), BorderLayout.SOUTH); + return result; + } + + private JPanel buttonsPanel() { + final JPanel result = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + addButton(result, "settings.gitea.add", this::addRow); + addButton(result, "settings.gitea.remove", this::removeSelectedRows); + addButton(result, "settings.gitea.setToken", this::setSelectedToken); + addButton(result, "settings.gitea.clearToken", this::clearSelectedToken); + return result; + } + + private void addButton(final JPanel panel, final String key, final Runnable action) { + final JButton button = new JButton(GitHubWorkflowBundle.message(key)); + button.addActionListener(event -> action.run()); + buttons.add(new LocalizedButton(button, key)); + panel.add(button); + } + + private void reloadTable() { + pendingTokens.clear(); + clearedTokens.clear(); + storedTokenRows.clear(); + model.setRowCount(0); + settings.customServers().stream() + .filter(RemoteActionProviders.Server::isGitea) + .forEach(this::addPersistedRow); + } + + private void addPersistedRow(final RemoteActionProviders.Server server) { + final int rowId = nextRowId(); + if (server.tokenStored) { + storedTokenRows.add(rowId); + } + model.addRow(new Object[]{ + server.enabled, + server.name, + server.webUrl, + server.apiUrl, + server.tokenEnvVar, + server.tokenStored ? STORED_TOKEN : "", + rowId + }); + } + + private void addRow() { + model.addRow(new Object[]{ + true, + "Gitea", + "https://gitea.com", + "https://gitea.com/api/v1", + "GITEA_TOKEN", + "", + nextRowId() + }); + } + + private void removeSelectedRows() { + final int[] rows = table.getSelectedRows(); + for (int index = rows.length - 1; index >= 0; index--) { + model.removeRow(table.convertRowIndexToModel(rows[index])); + } + } + + private void setSelectedToken() { + final int row = selectedRow(); + if (row < 0) { + Messages.showInfoMessage(panel, GitHubWorkflowBundle.message("settings.gitea.noneSelected"), getDisplayName()); + return; + } + final String token = Messages.showPasswordDialog(GitHubWorkflowBundle.message("settings.gitea.token.prompt"), getDisplayName()); + if (token != null && !token.isBlank()) { + final int rowId = rowId(row); + pendingTokens.put(rowId, token); + clearedTokens.remove(rowId); + storedTokenRows.add(rowId); + model.setValueAt(STORED_TOKEN, row, TOKEN); + } + } + + private void clearSelectedToken() { + final int row = selectedRow(); + if (row < 0) { + Messages.showInfoMessage(panel, GitHubWorkflowBundle.message("settings.gitea.noneSelected"), getDisplayName()); + return; + } + final int rowId = rowId(row); + pendingTokens.remove(rowId); + clearedTokens.add(rowId); + storedTokenRows.remove(rowId); + model.setValueAt("", row, TOKEN); + } + + private int selectedRow() { + final int viewRow = table.getSelectedRow(); + return viewRow < 0 ? -1 : table.convertRowIndexToModel(viewRow); + } + + private void saveRows() { + final List previous = settings.customServers(); + final List servers = serversFromTable(); + final List nextKeys = servers.stream().map(server -> server.apiUrl).toList(); + final List persisted = new ArrayList<>(previous.stream() + .filter(server -> !server.isGitea()) + .toList()); + persisted.addAll(servers); + + previous.stream() + .filter(RemoteActionProviders.Server::isGitea) + .filter(server -> !nextKeys.contains(server.apiUrl)) + .forEach(settings::clearGiteaToken); + + for (int row = 0; row < model.getRowCount(); row++) { + final RemoteActionProviders.Server server = serverFromRow(row); + if (!server.isValid()) { + continue; + } + final int rowId = rowId(row); + if (pendingTokens.containsKey(rowId)) { + settings.setGiteaToken(server, pendingTokens.get(rowId)); + } else if (clearedTokens.contains(rowId)) { + settings.clearGiteaToken(server); + } + } + settings.setCustomServers(persisted); + reloadTable(); + } + + private boolean tokenModified() { + return !pendingTokens.isEmpty() || !clearedTokens.isEmpty(); + } + + private int invalidRowCount() { + int result = 0; + for (int row = 0; row < model.getRowCount(); row++) { + if (!serverFromRow(row).isValid()) { + result++; + } + } + return result; + } + + private List serversFromTable() { + final List result = new ArrayList<>(); + for (int row = 0; row < model.getRowCount(); row++) { + final RemoteActionProviders.Server server = serverFromRow(row); + if (server.isValid()) { + result.add(server); + } + } + return result; + } + + private RemoteActionProviders.Server serverFromRow(final int row) { + return RemoteActionProviders.Server.gitea( + text(row, NAME), + text(row, WEB_URL), + text(row, API_URL), + text(row, TOKEN_ENV), + Boolean.TRUE.equals(model.getValueAt(row, ENABLED)), + storedTokenRows.contains(rowId(row)) + ).normalized(); + } + + private String text(final int row, final int column) { + return Objects.toString(model.getValueAt(row, column), "").trim(); + } + + private int rowId(final int row) { + final Object value = model.getValueAt(row, ROW_ID); + return value instanceof Integer id ? id : -1; + } + + private int nextRowId() { + final int next = nextRowSequence; + nextRowSequence++; + return next; + } + + private void refreshTexts() { + buttons.forEach(button -> button.component().setText(GitHubWorkflowBundle.message(button.key()))); + setColumnHeaders(); + if (border != null) { + border.setTitle(GitHubWorkflowBundle.message("settings.gitea.title")); + } + if (panel != null) { + panel.revalidate(); + panel.repaint(); + } + } + + private void setColumnHeaders() { + model.setColumnIdentifiers(new Object[]{ + GitHubWorkflowBundle.message("settings.gitea.column.enabled"), + GitHubWorkflowBundle.message("settings.gitea.column.name"), + GitHubWorkflowBundle.message("settings.gitea.column.webUrl"), + GitHubWorkflowBundle.message("settings.gitea.column.apiUrl"), + GitHubWorkflowBundle.message("settings.gitea.column.tokenEnv"), + GitHubWorkflowBundle.message("settings.gitea.column.token"), + "" + }); + if (table.getColumnModel().getColumnCount() > ROW_ID) { + table.removeColumn(table.getColumnModel().getColumn(ROW_ID)); + } + } + + private record LocalizedButton(JButton component, String key) { + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/Envs.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Envs.java index 690b7da..d1327a5 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/syntax/Envs.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Envs.java @@ -15,9 +15,9 @@ import java.util.function.Function; import java.util.stream.Collectors; -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.WorkflowContextCatalog.defaultEnvs; 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; @@ -54,7 +54,7 @@ public static List listEnvs(final PsiElement psiElement) { addWorkflowEnvs(psiElement, result); //DEFAULT ENVS - addDefaultEnvs(result); + addDefaultEnvs(psiElement, result); return result; } @@ -103,8 +103,8 @@ private static Function, Map> toMapWithKeyAnd .collect(Collectors.toMap(YAMLKeyValue::getKeyText, keyValue -> getText(keyValue).orElse(""), (existing, replacement) -> existing)); } - private static void addDefaultEnvs(final List result) { - result.addAll(completionItemsOf(DEFAULT_VALUE_MAP.get(FIELD_ENVS).get(), ICON_ENV)); + private static void addDefaultEnvs(final PsiElement psiElement, final List result) { + result.addAll(completionItemsOf(defaultEnvs(WorkflowSyntax.providerFor(psiElement)), ICON_ENV)); } private Envs() { diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/Secrets.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Secrets.java index 361425c..5dc1f6b 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/syntax/Secrets.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Secrets.java @@ -38,6 +38,7 @@ public class Secrets { private static final String GITHUB_TOKEN = "GITHUB_TOKEN"; + private static final String GITEA_TOKEN = "GITEA_TOKEN"; public static void highLightSecrets( final AnnotationHolder holder, @@ -86,7 +87,11 @@ public static List listSecrets(final PsiElement psiElement) { LinkedHashMap::new ))) .orElseGet(LinkedHashMap::new); - result.putIfAbsent(GITHUB_TOKEN, GitHubWorkflowBundle.message("completion.secret.githubToken")); + if (WorkflowSyntax.providerFor(psiElement) == WorkflowSyntax.Provider.GITEA) { + result.putIfAbsent(GITEA_TOKEN, GitHubWorkflowBundle.message("completion.secret.giteaToken")); + } else { + result.putIfAbsent(GITHUB_TOKEN, GitHubWorkflowBundle.message("completion.secret.githubToken")); + } return completionItemsOf(result, ICON_SECRET_WORKFLOW); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java index 5763958..3659a8b 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java @@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -53,7 +54,29 @@ public class WorkflowContextCatalog { public static final String FIELD_CONCLUSION = "conclusion"; public static final String FIELD_OUTCOME = "outcome"; public static final Map>> DEFAULT_VALUE_MAP = initProcessorMap(); - public static final Map SHELLS = initShells(); + private static final List GITEA_ENV_NAMES = List.of( + "GITEA_ACTIONS", + "GITEA_ACTIONS_RUNNER_VERSION", + "GITEA_ENV", + "GITEA_OUTPUT", + "GITEA_PATH", + "GITEA_STATE", + "GITEA_STEP_SUMMARY" + ); + private static final List GITHUB_EXPRESSION_FUNCTIONS = List.of( + "contains()", + "startsWith()", + "endsWith()", + "format()", + "join()", + "toJSON()", + "fromJSON()", + "hashFiles()", + "success()", + "always()", + "cancelled()", + "failure()" + ); private static Map>> initProcessorMap() { final Map>> result = new LinkedHashMap<>(); @@ -67,6 +90,53 @@ private static Map>> initProcessorMap() { return result; } + /** + * Returns built-in runner environment variables for the selected workflow provider. + * + * @param provider workflow provider inferred from the edited file + * @return immutable map keyed by environment variable name with localized descriptions + */ + public static Map defaultEnvs(final WorkflowSyntax.Provider provider) { + final Map result = new LinkedHashMap<>(getGitHubEnvs()); + if (provider == WorkflowSyntax.Provider.GITEA) { + GITEA_ENV_NAMES.forEach(name -> result.putIfAbsent(name, message("completion.env.gitea"))); + } + return Collections.unmodifiableMap(result); + } + + /** + * Returns root expression contexts and functions for the selected workflow provider. + * + * @param provider workflow provider inferred from the edited file + * @return immutable map keyed by expression token with localized descriptions + */ + public static Map expressionRoots(final WorkflowSyntax.Provider provider) { + final Map result = new LinkedHashMap<>(getCaretBracketItems()); + final List functions = provider == WorkflowSyntax.Provider.GITEA ? List.of("always()") : GITHUB_EXPRESSION_FUNCTIONS; + functions.forEach(function -> result.put(function, message("completion.expressionFunction"))); + return Collections.unmodifiableMap(result); + } + + /** + * Returns GitHub expression function names without parentheses. + * + * @return immutable list of known GitHub expression function names + */ + public static List githubExpressionFunctionNames() { + return GITHUB_EXPRESSION_FUNCTIONS.stream() + .map(function -> function.substring(0, function.indexOf('('))) + .toList(); + } + + /** + * Returns supported shell completion values with localized descriptions. + * + * @return immutable map keyed by shell name + */ + public static Map shells() { + return initShells(); + } + private static Map initShells() { final Map result = new LinkedHashMap<>(); result.put("bash", message("completion.shell.bash")); diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java index e13cb28..fe95264 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java @@ -59,7 +59,7 @@ public class WorkflowSyntax { new GitHubSchemaProvider("dependabot-2.0", "Dependabot", WorkflowYaml::isDependabotFile), new GitHubSchemaProvider("github-action", "GitHub Action", WorkflowYaml::isActionFile), new GitHubSchemaProvider("github-funding", "GitHub Funding", WorkflowYaml::isFoundingFile), - new GitHubSchemaProvider("github-workflow", "GitHub Workflow", WorkflowYaml::isWorkflowFile), + new GitHubSchemaProvider("github-workflow", "GitHub Workflow", WorkflowYaml::isGithubWorkflowFile), new GitHubSchemaProvider("github-discussion", "GitHub Discussion", WorkflowYaml::isDiscussionFile), new GitHubSchemaProvider("github-issue-forms", "GitHub Issue Forms", WorkflowYaml::isIssueForms), new GitHubSchemaProvider("github-issue-config", "GitHub Workflow Issue Template configuration", WorkflowYaml::isIssueConfigFile), @@ -75,22 +75,22 @@ public class WorkflowSyntax { rule( (path, completion) -> isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) || isChildOf(path, FIELD_ON, "workflow_call", FIELD_INPUTS), - ignored -> workflowInputPropertyKeys(), + (ignored, provider) -> workflowInputPropertyKeys(), "inspection.workflow.syntax.unknownTriggerKey" ), rule( (path, completion) -> isChildOf(path, FIELD_ON, "workflow_call", FIELD_OUTPUTS), - ignored -> workflowOutputPropertyKeys(), + (ignored, provider) -> workflowOutputPropertyKeys(), "inspection.workflow.syntax.unknownTriggerKey" ), rule( (path, completion) -> isChildOf(path, FIELD_ON, "workflow_call", FIELD_SECRETS), - ignored -> workflowSecretPropertyKeys(), + (ignored, provider) -> workflowSecretPropertyKeys(), "inspection.workflow.syntax.unknownTriggerKey" ), rule( (path, completion) -> pathMatches(path, FIELD_ON, "*"), - path -> eventFilterKeysFor(path.get(path.size() - 1)), + (path, provider) -> eventFilterKeysFor(path.get(path.size() - 1), provider), "inspection.workflow.syntax.unknownTriggerFilter" ), rule((path, completion) -> pathEndsWith(path, "permissions"), "permission", "inspection.workflow.syntax.unknownPermission"), @@ -120,6 +120,24 @@ public class WorkflowSyntax { private WorkflowSyntax() { } + public enum Provider { + GITHUB, + GITEA + } + + /** + * Detects the workflow syntax provider from the edited file path. + * + * @param element any PSI element inside the edited file + * @return {@link Provider#GITEA} for {@code .gitea/workflows}, otherwise {@link Provider#GITHUB} + */ + public static Provider providerFor(final PsiElement element) { + return WorkflowYaml.getWorkflowFile(element) + .filter(WorkflowYaml::isGiteaWorkflowFile) + .map(ignored -> Provider.GITEA) + .orElse(Provider.GITHUB); + } + 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); @@ -133,16 +151,22 @@ public Icon getIcon(@NotNull final PsiElement element, final int flags) { .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() - ) + .flatMap(FileIcon::iconForKnownFile) .orElse(null); } + private static Optional iconForKnownFile(final VirtualFile virtualFile) { + if (WorkflowPsi.toPath(virtualFile).filter(WorkflowYaml::isWorkflowFile).isPresent()) { + return Optional.of(iconFor(virtualFile)); + } + return SCHEMA_FILE_PROVIDERS.stream() + .filter(GitHubSchemaProvider.class::isInstance) + .map(GitHubSchemaProvider.class::cast) + .filter(schemaProvider -> schemaProvider.isAvailable(virtualFile)) + .map(schema -> iconFor(virtualFile)) + .findFirst(); + } + private static Icon iconFor(final VirtualFile virtualFile) { return isGiteaWorkflowFile(virtualFile) ? GITEA_ICON : AllIcons.Vcs.Vendors.Github; } @@ -381,28 +405,61 @@ static Map eventFilterKeys() { } public static Map eventFilterKeysFor(final String event) { - final Map result = table("eventFilter." + event); + return eventFilterKeysFor(event, Provider.GITHUB); + } + + /** + * Returns trigger filter keys valid for the selected workflow provider. + * + * @param event trigger event name + * @param provider syntax provider inferred from the workflow file + * @return localized filter keys keyed by YAML key name + */ + public static Map eventFilterKeysFor(final String event, final Provider provider) { + final Map result = table("eventFilter." + event, provider); return result.isEmpty() ? eventFilterKeys() : result; } public static Optional> completionKeysForPath(final List path) { - return knownKeysForPath(path, true).map(KnownKeys::values); + return completionKeysForPath(path, Provider.GITHUB); + } + + /** + * Returns structure completion keys for a YAML path and provider. + * + * @param path YAML key path before the caret + * @param provider syntax provider inferred from the workflow file + * @return localized completion keys when this plugin owns the path + */ + public static Optional> completionKeysForPath(final List path, final Provider provider) { + return knownKeysForPath(path, true, provider).map(KnownKeys::values); } public static Optional descriptionForKey(final YAMLKeyValue keyValue) { return WorkflowLocation.from(keyValue) .filter(WorkflowLocation::workflowFile) - .flatMap(location -> knownKeysForPath(location.path(), true) + .flatMap(location -> knownKeysForPath(location.path(), true, providerFor(keyValue)) .map(KnownKeys::values) .map(values -> values.get(location.keyValue().getKeyText()))) .filter(value -> !value.isBlank()); } public static Optional validationKeysForPath(final List path) { - return knownKeysForPath(path, false); + return validationKeysForPath(path, Provider.GITHUB); + } + + /** + * Returns validation keys for a YAML path and provider. + * + * @param path YAML key path at the inspected element + * @param provider syntax provider inferred from the workflow file + * @return allowed keys and diagnostic message when this plugin owns the path + */ + public static Optional validationKeysForPath(final List path, final Provider provider) { + return knownKeysForPath(path, false, provider); } - private static Optional knownKeysForPath(final List path, final boolean completion) { + private static Optional knownKeysForPath(final List path, final boolean completion, final Provider provider) { 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) @@ -410,12 +467,23 @@ private static Optional knownKeysForPath(final List path, fin return Optional.empty(); } return KEY_RULES.stream() - .flatMap(rule -> rule.known(path, completion).stream()) + .flatMap(rule -> rule.known(path, completion, provider).stream()) .findFirst(); } public static Map eventActivityTypesFor(final String event) { - return table("activity." + event); + return eventActivityTypesFor(event, Provider.GITHUB); + } + + /** + * Returns documented activity types for a trigger event. + * + * @param event trigger event name + * @param provider syntax provider inferred from the workflow file + * @return localized activity values keyed by YAML value + */ + public static Map eventActivityTypesFor(final String event, final Provider provider) { + return table("activity." + event, provider); } static Map permissionScopes() { @@ -427,14 +495,35 @@ static Map permissionValues() { } public static Map permissionValuesFor(final String permission) { + return permissionValuesFor(permission, Provider.GITHUB); + } + + /** + * Returns permission access values for a permission scope and provider. + * + * @param permission permission scope name + * @param provider syntax provider inferred from the workflow file + * @return localized access values keyed by YAML value + */ + public static Map permissionValuesFor(final String permission, final Provider provider) { final Map result = table("permissionValue." + permission); - return result.isEmpty() ? permissionValues() : result; + return provider == Provider.GITEA || result.isEmpty() ? permissionValues() : result; } public static Map permissionShorthandValues() { return table("permissionShorthand"); } + /** + * Returns shorthand permission values for the selected workflow provider. + * + * @param provider syntax provider inferred from the workflow file + * @return localized shorthand permission values keyed by YAML value + */ + public static Map permissionShorthandValues(final Provider provider) { + return table("permissionShorthand", provider); + } + static Map jobKeys() { return table("job"); } @@ -527,6 +616,26 @@ public static Map runnerLabels() { return table("runner"); } + /** + * Returns runner label completions for the selected workflow provider. + * + * @param provider syntax provider inferred from the workflow file + * @return localized runner labels keyed by YAML value + */ + public static Map runnerLabels(final Provider provider) { + return table("runner", provider); + } + + /** + * Returns schedule cron value completions for the selected workflow provider. + * + * @param provider syntax provider inferred from the workflow file + * @return localized cron values keyed by YAML value + */ + public static Map cronValues(final Provider provider) { + return table("cron", provider); + } + private static Map table(final String group) { final Map keys = Tables.DATA.getOrDefault(group, Collections.emptyMap()); if (keys.isEmpty()) { @@ -537,6 +646,30 @@ private static Map table(final String group) { return Collections.unmodifiableMap(result); } + private static Map table(final String group, final Provider provider) { + if (provider != Provider.GITEA) { + return table(group); + } + final Map gitea = table(group + ".gitea"); + if (gitea.isEmpty()) { + return table(group); + } + if (replacesGithubTable(group)) { + return gitea; + } + final Map result = new LinkedHashMap<>(table(group)); + result.putAll(gitea); + return Collections.unmodifiableMap(result); + } + + private static boolean replacesGithubTable(final String group) { + return "permission".equals(group) + || "permissionShorthand".equals(group) + || group.startsWith("permissionValue.") + || "runner".equals(group) + || "cron".equals(group); + } + private static Map> loadTables() { final Map> result = new LinkedHashMap<>(); try (BufferedReader reader = syntaxReader()) { @@ -588,7 +721,7 @@ private Tables() { } private static SyntaxRule rule(final PathPredicate predicate, final String table, final String messageKey) { - return rule(predicate, ignored -> table(table), messageKey); + return rule(predicate, (ignored, provider) -> table(table, provider), messageKey); } private static SyntaxRule rule(final PathPredicate predicate, final ValueProvider values, final String messageKey) { @@ -602,13 +735,13 @@ private interface PathPredicate { @FunctionalInterface private interface ValueProvider { - Map values(List path); + Map values(List path, Provider provider); } private record SyntaxRule(PathPredicate predicate, ValueProvider values, String messageKey) { - Optional known(final List path, final boolean completion) { + Optional known(final List path, final boolean completion, final Provider provider) { return predicate.matches(path, completion) - ? Optional.of(new KnownKeys(values.values(path), messageKey)) + ? Optional.of(new KnownKeys(values.values(path, provider), messageKey)) : Optional.empty(); } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowYaml.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowYaml.java index 2062ef9..ca24ed9 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowYaml.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowYaml.java @@ -287,6 +287,28 @@ && isYamlFile(path) || path.getName(path.getNameCount() - 3).toString().equalsIgnoreCase(".gitea")); } + /** + * Checks whether a path is a Gitea Actions workflow file. + * + * @param path file path to inspect + * @return true for YAML files under {@code .gitea/workflows} + */ + public static boolean isGiteaWorkflowFile(final Path path) { + return isWorkflowFile(path) + && path.getName(path.getNameCount() - 3).toString().equalsIgnoreCase(".gitea"); + } + + /** + * Checks whether a path is a GitHub Actions workflow file. + * + * @param path file path to inspect + * @return true for YAML files under {@code .github/workflows} + */ + public static boolean isGithubWorkflowFile(final Path path) { + return isWorkflowFile(path) + && path.getName(path.getNameCount() - 3).toString().equalsIgnoreCase(".github"); + } + public static boolean isWorkflowTemplatePropertiesFile(final Path path) { return path.getNameCount() > 2 && path.getName(path.getNameCount() - 3).toString().equalsIgnoreCase(".github") diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 9f76a32..9a551b6 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -55,6 +55,12 @@ parentId="tools" key="settings.displayName" bundle="messages.GitHubWorkflowBundle"/> + diff --git a/src/main/resources/github-docs/workflow-syntax.tsv b/src/main/resources/github-docs/workflow-syntax.tsv index f5eaa2c..1e7fdd5 100644 --- a/src/main/resources/github-docs/workflow-syntax.tsv +++ b/src/main/resources/github-docs/workflow-syntax.tsv @@ -202,6 +202,15 @@ 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 +permission.gitea actions completion.workflow.permission.actions +permission.gitea contents completion.workflow.permission.contents +permission.gitea code completion.workflow.permission.contents +permission.gitea releases completion.workflow.event.release +permission.gitea issues completion.workflow.permission.issues +permission.gitea pull-requests completion.workflow.permission.pull-requests +permission.gitea wiki completion.workflow.event.gollum +permission.gitea projects completion.workflow.event.project +permission.gitea packages completion.workflow.permission.packages permissionValue read completion.workflow.permission.value.read permissionValue write completion.workflow.permission.value.write permissionValue none completion.workflow.permission.value.none @@ -214,6 +223,13 @@ permissionValue.vulnerability-alerts none completion.workflow.permission.value.n permissionShorthand read-all completion.workflow.permission.shorthand.read-all permissionShorthand write-all completion.workflow.permission.shorthand.write-all permissionShorthand {} completion.workflow.permission.shorthand.empty +permissionShorthand.gitea read-all completion.workflow.permission.shorthand.read-all +permissionShorthand.gitea write-all completion.workflow.permission.shorthand.write-all +cron.gitea @yearly completion.workflow.eventFilter.cron +cron.gitea @monthly completion.workflow.eventFilter.cron +cron.gitea @weekly completion.workflow.eventFilter.cron +cron.gitea @daily completion.workflow.eventFilter.cron +cron.gitea @hourly completion.workflow.eventFilter.cron job name completion.workflow.job.name job permissions completion.workflow.job.permissions job needs completion.workflow.job.needs @@ -234,6 +250,9 @@ job services completion.workflow.job.services job uses completion.workflow.job.uses job with completion.workflow.job.with job secrets completion.workflow.job.secrets +job.gitea environment completion.workflow.job.gitea.ignored +job.gitea timeout-minutes completion.workflow.job.gitea.ignored +job.gitea continue-on-error completion.workflow.job.gitea.ignored defaultsRun shell completion.workflow.defaultsRun.shell defaultsRun working-directory completion.workflow.defaultsRun.working-directory concurrency group completion.workflow.concurrency.group diff --git a/src/main/resources/messages/GitHubWorkflowBundle.properties b/src/main/resources/messages/GitHubWorkflowBundle.properties index 7de3c27..db9bbe0 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub returned an HTML error page instead of API data. workflow.run.gutter.stop=Stop workflow run workflow.run.gutter.stop.text=Stop workflow run workflow.run.gutter.stop.description=Cancel this run -workflow.run.auth.settings=Settings > Version Control > GitHub +workflow.run.auth.settings.github=Settings > Version Control > GitHub +workflow.run.auth.settings.gitea=Settings > Version Control > Gitea +workflow.run.auth.add=Add a token or account in {0}. workflow.log.command=run: workflow.log.warning=warning: workflow.log.error=error: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Unknown permission [{0}] inspection.workflow.syntax.unknownPermissionValue=Unknown permission value [{0}] inspection.workflow.syntax.unknownJobKey=Unknown job key [{0}] inspection.workflow.syntax.unknownStepKey=Unknown step key [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea supports one runner label here +inspection.workflow.syntax.giteaExpressionFunction=Gitea only documents always() here: [{0}] inspection.action.reload=Reload [{0}] inspection.action.unresolved=Unresolved [{0}] - check GitHub account access, private repository permissions, rate limits, missing refs, or missing action/workflow metadata inspection.action.jump=Jump to file [{0}] @@ -215,6 +219,9 @@ completion.context.github=Workflow run and event information from the GitHub con completion.context.gitea=Gitea-compatible alias for the GitHub Actions context. completion.context.runner=Information about the runner executing the current job. completion.secret.githubToken=Automatically created token for each workflow run. +completion.secret.giteaToken=Automatically created Gitea token for each workflow run. +completion.env.gitea=Gitea runner environment variable. +completion.expressionFunction=Expression function. Tiny logic spell. completion.remote.repository=Remote repository completion.uses.local.workflow=Local reusable workflow completion.uses.local.action=Local action @@ -317,6 +324,7 @@ completion.workflow.job.services=Sidecar service containers completion.workflow.job.uses=Reusable workflow to call completion.workflow.job.with=Inputs for called workflow completion.workflow.job.secrets=Secrets for called workflow +completion.workflow.job.gitea.ignored=Accepted by Gitea, ignored at runtime. completion.workflow.defaultsRun.shell=Default shell for run steps completion.workflow.defaultsRun.working-directory=Default working directory completion.workflow.concurrency.group=Lock name for queued runs @@ -376,8 +384,23 @@ completion.steps.outcome=The result of a completed step before continue-on-error completion.jobs.outputs=The set of outputs defined for the job. completion.jobs.result=The result of the job. settings.displayName=GitHub Workflow +settings.gitea.displayName=Gitea settings.language.label=Language: settings.language.system=IDE/system default +settings.gitea.title=Gitea accounts +settings.gitea.column.enabled=On +settings.gitea.column.name=Name +settings.gitea.column.webUrl=Web URL +settings.gitea.column.apiUrl=API URL +settings.gitea.column.tokenEnv=Token env +settings.gitea.column.token=Token +settings.gitea.add=Add Gitea +settings.gitea.remove=Remove +settings.gitea.setToken=Set token +settings.gitea.clearToken=Clear token +settings.gitea.noneSelected=Select a Gitea row first. +settings.gitea.invalidRows=Fix invalid Gitea rows first. Name, web URL, and API URL need text. +settings.gitea.token.prompt=Paste Gitea token settings.cache.title=Action cache settings.cache.column.key=Cache key settings.cache.column.name=Name diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ar.properties b/src/main/resources/messages/GitHubWorkflowBundle_ar.properties index b31bd60..f33c295 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ar.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ar.properties @@ -29,7 +29,9 @@ workflow.run.error.html=أعاد GitHub صفحة خطأ HTML بدلًا من ب workflow.run.gutter.stop=إيقاف تشغيل سير العمل workflow.run.gutter.stop.text=إيقاف تشغيل سير العمل workflow.run.gutter.stop.description=إلغاء هذا التشغيل -workflow.run.auth.settings=الإعدادات > التحكم بالإصدارات > GitHub +workflow.run.auth.settings.github=الإعدادات > التحكم بالإصدار > GitHub +workflow.run.auth.settings.gitea=الإعدادات > التحكم بالإصدار > Gitea +workflow.run.auth.add=أضف رمزا أو حسابا في {0}. workflow.log.command=يجري: workflow.log.warning=تحذير: workflow.log.error=خطأ: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=إذن غير معروف [{0}] inspection.workflow.syntax.unknownPermissionValue=قيمة إذن غير معروفة [{0}] inspection.workflow.syntax.unknownJobKey=مفتاح مهمة غير معروف [{0}] inspection.workflow.syntax.unknownStepKey=مفتاح خطوة غير معروف [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=يدعم Gitea هنا تسمية runner واحدة فقط +inspection.workflow.syntax.giteaExpressionFunction=يوثق Gitea هنا always() فقط: [{0}] inspection.action.reload=إعادة تحميل [{0}] inspection.action.unresolved=لم يتم حل [{0}] - تحقق من الوصول إلى حساب GitHub، أو أذونات المستودع الخاص، أو حدود المعدل، أو المراجع المفقودة، أو بيانات تعريف الإجراء/سير العمل المفقودة inspection.action.jump=انتقل إلى الملف [{0}] @@ -215,6 +219,9 @@ completion.context.github=تشغيل سير العمل ومعلومات الحد completion.context.gitea=الاسم المستعار المتوافق مع Gitea لسياق إجراءات GitHub. completion.context.runner=معلومات حول العداء الذي ينفذ الوظيفة الحالية. completion.secret.githubToken=الرمز المميز الذي تم إنشاؤه تلقائيًا لكل تشغيل سير عمل. +completion.secret.giteaToken=رمز Gitea يُنشأ تلقائياً لكل تشغيل workflow. +completion.env.gitea=متغير بيئة runner في Gitea. +completion.expressionFunction=دالة تعبير. تعويذة منطق صغيرة. completion.remote.repository=المستودع البعيد completion.uses.local.workflow=سير العمل المحلي القابل لإعادة الاستخدام completion.uses.local.action=العمل المحلي @@ -317,6 +324,7 @@ completion.workflow.job.services=حاويات خدمة Sidecar completion.workflow.job.uses=سير العمل القابل لإعادة الاستخدام للاتصال completion.workflow.job.with=مدخلات لسير العمل يسمى completion.workflow.job.secrets=أسرار تسمى سير العمل +completion.workflow.job.gitea.ignored=يقبله Gitea ويتجاهله وقت التشغيل. completion.workflow.defaultsRun.shell=الغلاف الافتراضي لخطوات التشغيل completion.workflow.defaultsRun.working-directory=دليل العمل الافتراضي completion.workflow.concurrency.group=اسم القفل لعمليات التشغيل في قائمة الانتظار @@ -376,8 +384,23 @@ completion.steps.outcome=يتم تطبيق نتيجة الخطوة المكتم completion.jobs.outputs=مجموعة المخرجات المحددة للوظيفة. completion.jobs.result=نتيجة الوظيفة. settings.displayName=سير العمل GitHub +settings.gitea.displayName=Gitea settings.language.label=لغة: settings.language.system=IDE/النظام الافتراضي +settings.gitea.title=حسابات Gitea +settings.gitea.column.enabled=مفعل +settings.gitea.column.name=الاسم +settings.gitea.column.webUrl=رابط الويب +settings.gitea.column.apiUrl=رابط API +settings.gitea.column.tokenEnv=متغير الرمز +settings.gitea.column.token=رمز الوصول +settings.gitea.add=أضف Gitea +settings.gitea.remove=إزالة +settings.gitea.setToken=تعيين الرمز +settings.gitea.clearToken=مسح الرمز +settings.gitea.noneSelected=اختر صف Gitea أولا. +settings.gitea.invalidRows=أصلح صفوف Gitea غير الصالحة أولا. الاسم ورابط الويب ورابط API تحتاج نصا. +settings.gitea.token.prompt=الصق رمز Gitea settings.cache.title=ذاكرة التخزين المؤقت للعمل settings.cache.column.key=مفتاح ذاكرة التخزين المؤقت settings.cache.column.name=اسم diff --git a/src/main/resources/messages/GitHubWorkflowBundle_cs.properties b/src/main/resources/messages/GitHubWorkflowBundle_cs.properties index cf43d86..b8277c7 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_cs.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_cs.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub vrátil chybovou stránku HTML místo dat API. workflow.run.gutter.stop=Zastavit běh pracovního postupu workflow.run.gutter.stop.text=Zastavit běh pracovního postupu workflow.run.gutter.stop.description=Zrušte tento běh -workflow.run.auth.settings=Nastavení > Správa verzí > GitHub +workflow.run.auth.settings.github=Nastavení > Správa verzí > GitHub +workflow.run.auth.settings.gitea=Nastavení > Správa verzí > Gitea +workflow.run.auth.add=Přidej token nebo účet v {0}. workflow.log.command=spustit: workflow.log.warning=varování: workflow.log.error=chyba: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Neznámé oprávnění [{0}] inspection.workflow.syntax.unknownPermissionValue=Neznámá hodnota oprávnění [{0}] inspection.workflow.syntax.unknownJobKey=Neznámý klíč úlohy [{0}] inspection.workflow.syntax.unknownStepKey=Neznámý krokový klíč [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea zde podporuje jeden štítek runneru +inspection.workflow.syntax.giteaExpressionFunction=Gitea zde dokumentuje jen always(): [{0}] inspection.action.reload=Znovu načíst [{0}] inspection.action.unresolved=Nevyřešeno [{0}] – zkontrolujte přístup k účtu GitHub, oprávnění soukromého úložiště, limity sazeb, chybějící odkazy nebo chybějící metadata akce/pracovního postupu inspection.action.jump=Přejít na soubor [{0}] @@ -215,6 +219,9 @@ completion.context.github=Informace o běhu pracovního postupu a událostech z completion.context.gitea=Alias kompatibilní s Gitea pro kontext akcí GitHub. completion.context.runner=Informace o běžci, který provádí aktuální úlohu. completion.secret.githubToken=Automaticky vytvořený token pro každé spuštění pracovního postupu. +completion.secret.giteaToken=Automaticky vytvořený token Gitea pro každý běh. +completion.env.gitea=Proměnná prostředí runneru Gitea. +completion.expressionFunction=Výrazová funkce. Malé kouzlo logiky. completion.remote.repository=Vzdálené úložiště completion.uses.local.workflow=Místní opakovaně použitelný pracovní postup completion.uses.local.action=Místní akce @@ -317,6 +324,7 @@ completion.workflow.job.services=Servisní kontejnery postranních vozíků completion.workflow.job.uses=Opakovaně použitelný pracovní postup pro volání completion.workflow.job.with=Vstupy pro volaný pracovní postup completion.workflow.job.secrets=Tajemství pro tzv. workflow +completion.workflow.job.gitea.ignored=Gitea přijme, za běhu ignoruje. completion.workflow.defaultsRun.shell=Výchozí shell pro kroky běhu completion.workflow.defaultsRun.working-directory=Výchozí pracovní adresář completion.workflow.concurrency.group=Název zámku pro běhy ve frontě @@ -376,8 +384,23 @@ completion.steps.outcome=Použije se výsledek dokončeného kroku před pokrač completion.jobs.outputs=Sada výstupů definovaných pro úlohu. completion.jobs.result=Výsledek práce. settings.displayName=Pracovní postup GitHub +settings.gitea.displayName=Gitea settings.language.label=jazyk: settings.language.system=IDE/výchozí nastavení systému +settings.gitea.title=Účty Gitea +settings.gitea.column.enabled=Zapnuto +settings.gitea.column.name=Název +settings.gitea.column.webUrl=Webová URL +settings.gitea.column.apiUrl=API URL adresa +settings.gitea.column.tokenEnv=Token proměnná +settings.gitea.column.token=Přístupový token +settings.gitea.add=Přidat Gitea +settings.gitea.remove=Odebrat +settings.gitea.setToken=Nastavit token +settings.gitea.clearToken=Smazat token +settings.gitea.noneSelected=Nejdřív vyber řádek Gitea. +settings.gitea.invalidRows=Nejdřív oprav neplatné řádky Gitea. Název, webová URL a API URL potřebují text. +settings.gitea.token.prompt=Vlož token Gitea settings.cache.title=Mezipaměť akcí settings.cache.column.key=Klíč mezipaměti settings.cache.column.name=Jméno diff --git a/src/main/resources/messages/GitHubWorkflowBundle_de.properties b/src/main/resources/messages/GitHubWorkflowBundle_de.properties index 681c372..bfb2cdb 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_de.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_de.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub gab eine HTML-Fehlerseite statt API-Daten zurück workflow.run.gutter.stop=Stoppen Sie die Workflow-Ausführung workflow.run.gutter.stop.text=Stoppen Sie die Workflow-Ausführung workflow.run.gutter.stop.description=Brechen Sie diesen Lauf ab -workflow.run.auth.settings=Einstellungen > Versionskontrolle > GitHub +workflow.run.auth.settings.github=Einstellungen > Versionskontrolle > GitHub +workflow.run.auth.settings.gitea=Einstellungen > Versionskontrolle > Gitea +workflow.run.auth.add=Token oder Konto in {0} hinzufügen. workflow.log.command=Lauf: workflow.log.warning=Warnung: workflow.log.error=Fehler: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Unbekannte Berechtigung [{0}] inspection.workflow.syntax.unknownPermissionValue=Unbekannter Berechtigungswert [{0}] inspection.workflow.syntax.unknownJobKey=Unbekannter Jobschlüssel [{0}] inspection.workflow.syntax.unknownStepKey=Unbekannter Schrittschlüssel [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea unterstützt hier nur ein Runner-Label +inspection.workflow.syntax.giteaExpressionFunction=Gitea dokumentiert hier nur always(): [{0}] inspection.action.reload=Neu laden [{0}] inspection.action.unresolved=Nicht gelöst [{0}] – Überprüfen Sie den GitHub-Kontozugriff, private Repository-Berechtigungen, Ratenbeschränkungen, fehlende Referenzen oder fehlende Aktions-/Workflow-Metadaten inspection.action.jump=Zur Datei springen [{0}] @@ -215,6 +219,9 @@ completion.context.github=Workflow-Ausführungs- und Ereignisinformationen aus d completion.context.gitea=Gitea-kompatibler Alias für den GitHub-Aktionskontext. completion.context.runner=Informationen über den Runner, der den aktuellen Job ausführt. completion.secret.githubToken=Automatisch erstelltes Token für jeden Workflow-Lauf. +completion.secret.giteaToken=Automatisch erstelltes Gitea-Token für jeden Workflow-Lauf. +completion.env.gitea=Gitea-Runner-Umgebungsvariable. +completion.expressionFunction=Ausdrucksfunktion. Kleine Logik-Magie. completion.remote.repository=Remote-Repository completion.uses.local.workflow=Lokaler wiederverwendbarer Workflow completion.uses.local.action=Lokale Aktion @@ -317,6 +324,7 @@ completion.workflow.job.services=Beiwagen-Servicecontainer completion.workflow.job.uses=Wiederverwendbarer Workflow zum Aufrufen completion.workflow.job.with=Eingaben für den aufgerufenen Workflow completion.workflow.job.secrets=Geheimnisse für den aufgerufenen Workflow +completion.workflow.job.gitea.ignored=Von Gitea akzeptiert, zur Laufzeit ignoriert. completion.workflow.defaultsRun.shell=Standard-Shell für Ausführungsschritte completion.workflow.defaultsRun.working-directory=Standardarbeitsverzeichnis completion.workflow.concurrency.group=Sperrname für Ausführungen in der Warteschlange @@ -376,8 +384,23 @@ completion.steps.outcome=Das Ergebnis eines abgeschlossenen Schritts, bevor „C completion.jobs.outputs=Der für den Job definierte Satz von Ausgaben. completion.jobs.result=Das Ergebnis der Arbeit. settings.displayName=GitHub-Workflow +settings.gitea.displayName=Gitea settings.language.label=Sprache: settings.language.system=IDE/Systemstandard +settings.gitea.title=Gitea-Konten +settings.gitea.column.enabled=Aktiv +settings.gitea.column.name=Bezeichnung +settings.gitea.column.webUrl=Web-URL +settings.gitea.column.apiUrl=API-URL +settings.gitea.column.tokenEnv=Token-Env +settings.gitea.column.token=Zugriffstoken +settings.gitea.add=Gitea hinzufügen +settings.gitea.remove=Entfernen +settings.gitea.setToken=Token setzen +settings.gitea.clearToken=Token löschen +settings.gitea.noneSelected=Erst eine Gitea-Zeile wählen. +settings.gitea.invalidRows=Ungültige Gitea-Zeilen erst reparieren. Name, Web-URL und API-URL brauchen Text. +settings.gitea.token.prompt=Gitea-Token einfügen settings.cache.title=Aktionscache settings.cache.column.key=Cache-Schlüssel settings.cache.column.name=Bezeichnung diff --git a/src/main/resources/messages/GitHubWorkflowBundle_es.properties b/src/main/resources/messages/GitHubWorkflowBundle_es.properties index b3d79c8..fafe8c5 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_es.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_es.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub devolvió una página de error HTML en lugar de d workflow.run.gutter.stop=Detener la ejecución del flujo de trabajo workflow.run.gutter.stop.text=Detener la ejecución del flujo de trabajo workflow.run.gutter.stop.description=Cancelar esta ejecución -workflow.run.auth.settings=Configuración > Control de versiones > GitHub +workflow.run.auth.settings.github=Ajustes > Control de versiones > GitHub +workflow.run.auth.settings.gitea=Ajustes > Control de versiones > Gitea +workflow.run.auth.add=Añade un token o cuenta en {0}. workflow.log.command=ejecutar: workflow.log.warning=advertencia: workflow.log.error=error: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Permiso desconocido [{0}] inspection.workflow.syntax.unknownPermissionValue=Valor de permiso desconocido [{0}] inspection.workflow.syntax.unknownJobKey=Clave de trabajo desconocida [{0}] inspection.workflow.syntax.unknownStepKey=Tecla de paso desconocida [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea admite aquí una sola etiqueta de runner +inspection.workflow.syntax.giteaExpressionFunction=Gitea solo documenta always() aquí: [{0}] inspection.action.reload=Recargar [{0}] inspection.action.unresolved=Sin resolver [{0}]: verifique el acceso a la cuenta GitHub, los permisos del repositorio privado, los límites de velocidad, las referencias faltantes o los metadatos de acción/flujo de trabajo faltantes inspection.action.jump=Saltar al archivo [{0}] @@ -215,6 +219,9 @@ completion.context.github=Información de eventos y ejecución del flujo de trab completion.context.gitea=Alias compatible con Gitea para el contexto de acciones GitHub. completion.context.runner=Información sobre el corredor que ejecuta el trabajo actual. completion.secret.githubToken=Token creado automáticamente para cada ejecución de flujo de trabajo. +completion.secret.giteaToken=Token de Gitea creado automáticamente para cada ejecución. +completion.env.gitea=Variable de entorno del runner de Gitea. +completion.expressionFunction=Función de expresión. Hechizo lógico mini. completion.remote.repository=repositorio remoto completion.uses.local.workflow=Flujo de trabajo local reutilizable completion.uses.local.action=Acción local @@ -317,6 +324,7 @@ completion.workflow.job.services=Contenedores de servicio con sidecar completion.workflow.job.uses=Flujo de trabajo reutilizable para llamar completion.workflow.job.with=Entradas para el flujo de trabajo llamado completion.workflow.job.secrets=Secretos para el flujo de trabajo llamado +completion.workflow.job.gitea.ignored=Gitea lo acepta y lo ignora en ejecución. completion.workflow.defaultsRun.shell=Shell predeterminado para los pasos de ejecución completion.workflow.defaultsRun.working-directory=Directorio de trabajo predeterminado completion.workflow.concurrency.group=Bloquear nombre para ejecuciones en cola @@ -376,8 +384,23 @@ completion.steps.outcome=Se aplica el resultado de un paso completado antes de c completion.jobs.outputs=El conjunto de salidas definidas para el trabajo. completion.jobs.result=El resultado del trabajo. settings.displayName=Flujo de trabajo GitHub +settings.gitea.displayName=Gitea settings.language.label=Idioma: settings.language.system=IDE/valor predeterminado del sistema +settings.gitea.title=Cuentas de Gitea +settings.gitea.column.enabled=Activo +settings.gitea.column.name=Nombre +settings.gitea.column.webUrl=URL web +settings.gitea.column.apiUrl=URL de API +settings.gitea.column.tokenEnv=Env del token +settings.gitea.column.token=Token de acceso +settings.gitea.add=Añadir Gitea +settings.gitea.remove=Quitar +settings.gitea.setToken=Guardar token +settings.gitea.clearToken=Borrar token +settings.gitea.noneSelected=Selecciona primero una fila Gitea. +settings.gitea.invalidRows=Arregla primero las filas Gitea inválidas. Nombre, URL web y URL API necesitan texto. +settings.gitea.token.prompt=Pega el token de Gitea settings.cache.title=Caché de acciones settings.cache.column.key=clave de caché settings.cache.column.name=Nombre diff --git a/src/main/resources/messages/GitHubWorkflowBundle_fr.properties b/src/main/resources/messages/GitHubWorkflowBundle_fr.properties index 6b3d2c6..2818ee4 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_fr.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_fr.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub a renvoyé une page d’erreur HTML au lieu des d workflow.run.gutter.stop=Arrêter l''exécution du workflow workflow.run.gutter.stop.text=Arrêter l''exécution du workflow workflow.run.gutter.stop.description=Annuler cette course -workflow.run.auth.settings=Paramètres > Contrôle de version > GitHub +workflow.run.auth.settings.github=Paramètres > Contrôle de version > GitHub +workflow.run.auth.settings.gitea=Paramètres > Contrôle de version > Gitea +workflow.run.auth.add=Ajoutez un jeton ou un compte dans {0}. workflow.log.command=exécuter : workflow.log.warning=avertissement : workflow.log.error=erreur : @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Autorisation inconnue [{0}] inspection.workflow.syntax.unknownPermissionValue=Valeur d''autorisation inconnue [{0}] inspection.workflow.syntax.unknownJobKey=Clé de travail inconnue [{0}] inspection.workflow.syntax.unknownStepKey=Clé d''étape inconnue [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea accepte ici une seule étiquette de runner +inspection.workflow.syntax.giteaExpressionFunction=Gitea ne documente ici que always() : [{0}] inspection.action.reload=Recharger [{0}] inspection.action.unresolved=Non résolu [{0}] - vérifiez l''accès au compte GitHub, les autorisations du référentiel privé, les limites de débit, les références manquantes ou les métadonnées d''action/workflow manquantes inspection.action.jump=Aller au fichier [{0}] @@ -215,6 +219,9 @@ completion.context.github=Informations sur l''exécution et les événements du completion.context.gitea=Alias compatible Gitea pour le contexte d''actions GitHub. completion.context.runner=Informations sur le coureur exécutant la tâche en cours. completion.secret.githubToken=Jeton créé automatiquement pour chaque exécution de flux de travail. +completion.secret.giteaToken=Jeton Gitea créé automatiquement pour chaque exécution. +completion.env.gitea=Variable d’environnement du runner Gitea. +completion.expressionFunction=Fonction d’expression. Petit sort logique. completion.remote.repository=Dépôt distant completion.uses.local.workflow=Flux de travail local réutilisable completion.uses.local.action=Action locale @@ -317,6 +324,7 @@ completion.workflow.job.services=Conteneurs de service side-car completion.workflow.job.uses=Workflow réutilisable à appeler completion.workflow.job.with=Entrées pour le workflow appelé completion.workflow.job.secrets=Secrets du flux de travail appelé +completion.workflow.job.gitea.ignored=Accepté par Gitea, ignoré à l’exécution. completion.workflow.defaultsRun.shell=Shell par défaut pour les étapes d''exécution completion.workflow.defaultsRun.working-directory=Répertoire de travail par défaut completion.workflow.concurrency.group=Nom du verrou pour les exécutions en file d''attente @@ -376,8 +384,23 @@ completion.steps.outcome=Le résultat d’une étape terminée avant l’applica completion.jobs.outputs=L''ensemble des sorties définies pour le travail. completion.jobs.result=Le résultat du travail. settings.displayName=Flux de travail GitHub +settings.gitea.displayName=Gitea settings.language.label=Langue : settings.language.system=IDE/système par défaut +settings.gitea.title=Comptes Gitea +settings.gitea.column.enabled=Actif +settings.gitea.column.name=Nom +settings.gitea.column.webUrl=URL web +settings.gitea.column.apiUrl=URL API +settings.gitea.column.tokenEnv=Env du jeton +settings.gitea.column.token=Jeton accès +settings.gitea.add=Ajouter Gitea +settings.gitea.remove=Retirer +settings.gitea.setToken=Définir jeton +settings.gitea.clearToken=Effacer jeton +settings.gitea.noneSelected=Sélectionnez d’abord une ligne Gitea. +settings.gitea.invalidRows=Corrigez d’abord les lignes Gitea invalides. Nom, URL web et URL API doivent contenir du texte. +settings.gitea.token.prompt=Collez le jeton Gitea settings.cache.title=Cache d''actions settings.cache.column.key=Clé de cache settings.cache.column.name=Nom diff --git a/src/main/resources/messages/GitHubWorkflowBundle_hi.properties b/src/main/resources/messages/GitHubWorkflowBundle_hi.properties index 65f6b80..6e7d1ed 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_hi.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_hi.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub ने API डेटा के बजाय HTML workflow.run.gutter.stop=वर्कफ़्लो चलाना बंद करें workflow.run.gutter.stop.text=वर्कफ़्लो चलाना बंद करें workflow.run.gutter.stop.description=यह रन रद्द करें -workflow.run.auth.settings=सेटिंग्स > संस्करण नियंत्रण > GitHub +workflow.run.auth.settings.github=सेटिंग्स > वर्शन कंट्रोल > GitHub +workflow.run.auth.settings.gitea=सेटिंग्स > वर्शन कंट्रोल > Gitea +workflow.run.auth.add={0} में टोकन या खाता जोड़ें. workflow.log.command=चलाएँ: workflow.log.warning=चेतावनी: workflow.log.error=त्रुटि: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=अज्ञात अनुमत inspection.workflow.syntax.unknownPermissionValue=अज्ञात अनुमति मान [{0}] inspection.workflow.syntax.unknownJobKey=अज्ञात कार्य कुंजी [{0}] inspection.workflow.syntax.unknownStepKey=अज्ञात चरण कुंजी [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea यहाँ एक ही runner लेबल समर्थन करता है +inspection.workflow.syntax.giteaExpressionFunction=Gitea यहाँ केवल always() दर्ज करता है: [{0}] inspection.action.reload=पुनः लोड करें [{0}] inspection.action.unresolved=अनसुलझा [{0}] - GitHub खाता पहुंच, निजी रिपॉजिटरी अनुमतियाँ, दर सीमा, गुम रेफरी, या गुम कार्रवाई/वर्कफ़्लो मेटाडेटा की जाँच करें inspection.action.jump=फ़ाइल पर जाएं [{0}] @@ -215,6 +219,9 @@ completion.context.github=GitHub संदर्भ से वर्कफ़् completion.context.gitea=GitHub क्रियाएँ संदर्भ के लिए Gitea-संगत उपनाम। completion.context.runner=वर्तमान कार्य निष्पादित करने वाले धावक के बारे में जानकारी। completion.secret.githubToken=प्रत्येक वर्कफ़्लो रन के लिए स्वचालित रूप से टोकन बनाया गया। +completion.secret.giteaToken=हर workflow रन के लिए अपने-आप बना Gitea टोकन। +completion.env.gitea=Gitea runner पर्यावरण चर। +completion.expressionFunction=अभिव्यक्ति फ़ंक्शन। छोटी लॉजिक जादूगरी। completion.remote.repository=दूरस्थ भंडार completion.uses.local.workflow=स्थानीय पुन: प्रयोज्य वर्कफ़्लो completion.uses.local.action=स्थानीय कार्रवाई @@ -317,6 +324,7 @@ completion.workflow.job.services=साइडकार सेवा कंटे completion.workflow.job.uses=कॉल करने के लिए पुन: प्रयोज्य वर्कफ़्लो completion.workflow.job.with=बुलाए गए वर्कफ़्लो के लिए इनपुट completion.workflow.job.secrets=बुलाए गए वर्कफ़्लो के लिए रहस्य +completion.workflow.job.gitea.ignored=Gitea स्वीकार करता है, runtime में अनदेखा करता है। completion.workflow.defaultsRun.shell=रन चरणों के लिए डिफ़ॉल्ट शेल completion.workflow.defaultsRun.working-directory=डिफ़ॉल्ट कार्यशील निर्देशिका completion.workflow.concurrency.group=कतारबद्ध रन के लिए लॉक नाम @@ -376,8 +384,23 @@ completion.steps.outcome=जारी रखें-त्रुटि लाग completion.jobs.outputs=कार्य के लिए परिभाषित आउटपुट का सेट। completion.jobs.result=कार्य का परिणाम. settings.displayName=GitHub वर्कफ़्लो +settings.gitea.displayName=Gitea settings.language.label=भाषा: settings.language.system=IDE/सिस्टम डिफ़ॉल्ट +settings.gitea.title=Gitea खाते +settings.gitea.column.enabled=चालू +settings.gitea.column.name=नाम +settings.gitea.column.webUrl=वेब URL +settings.gitea.column.apiUrl=API URL पता +settings.gitea.column.tokenEnv=टोकन env +settings.gitea.column.token=पहुंच टोकन +settings.gitea.add=Gitea जोड़ें +settings.gitea.remove=हटाएं +settings.gitea.setToken=टोकन सेट करें +settings.gitea.clearToken=टोकन साफ करें +settings.gitea.noneSelected=पहले Gitea पंक्ति चुनें. +settings.gitea.invalidRows=पहले अमान्य Gitea पंक्तियां ठीक करें. नाम, वेब URL और API URL चाहिए. +settings.gitea.token.prompt=Gitea टोकन चिपकाएं settings.cache.title=एक्शन कैश settings.cache.column.key=कैश कुंजी settings.cache.column.name=नाम diff --git a/src/main/resources/messages/GitHubWorkflowBundle_id.properties b/src/main/resources/messages/GitHubWorkflowBundle_id.properties index 5115258..1e290ea 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_id.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_id.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub mengembalikan halaman galat HTML, bukan data API. workflow.run.gutter.stop=Hentikan alur kerja yang dijalankan workflow.run.gutter.stop.text=Hentikan alur kerja yang dijalankan workflow.run.gutter.stop.description=Batalkan proses ini -workflow.run.auth.settings=Pengaturan > Kontrol Versi > GitHub +workflow.run.auth.settings.github=Pengaturan > Kontrol Versi > GitHub +workflow.run.auth.settings.gitea=Pengaturan > Kontrol Versi > Gitea +workflow.run.auth.add=Tambahkan token atau akun di {0}. workflow.log.command=menjalankan: workflow.log.warning=peringatan: workflow.log.error=kesalahan: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Izin tidak diketahui [{0}] inspection.workflow.syntax.unknownPermissionValue=Nilai izin tidak diketahui [{0}] inspection.workflow.syntax.unknownJobKey=Kunci pekerjaan tidak diketahui [{0}] inspection.workflow.syntax.unknownStepKey=Kunci langkah tidak diketahui [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea mendukung satu label runner di sini +inspection.workflow.syntax.giteaExpressionFunction=Gitea hanya mendokumentasikan always() di sini: [{0}] inspection.action.reload=Muat ulang [{0}] inspection.action.unresolved=[{0}] yang belum terselesaikan - periksa akses akun GitHub, izin repositori pribadi, batas kapasitas, referensi yang hilang, atau metadata tindakan/alur kerja yang hilang inspection.action.jump=Lompat ke file [{0}] @@ -215,6 +219,9 @@ completion.context.github=Alur kerja dijalankan dan informasi peristiwa dari kon completion.context.gitea=Alias ​​yang kompatibel dengan Gitea untuk konteks Tindakan GitHub. completion.context.runner=Informasi tentang pelari yang menjalankan pekerjaan saat ini. completion.secret.githubToken=Token yang dibuat secara otomatis untuk setiap alur kerja yang dijalankan. +completion.secret.giteaToken=Token Gitea otomatis untuk setiap eksekusi workflow. +completion.env.gitea=Variabel lingkungan runner Gitea. +completion.expressionFunction=Fungsi ekspresi. Mantra logika mini. completion.remote.repository=Repositori jarak jauh completion.uses.local.workflow=Alur kerja lokal yang dapat digunakan kembali completion.uses.local.action=Tindakan lokal @@ -317,6 +324,7 @@ completion.workflow.job.services=Kontainer servis sespan completion.workflow.job.uses=Alur kerja yang dapat digunakan kembali untuk menelepon completion.workflow.job.with=Input untuk alur kerja yang disebut completion.workflow.job.secrets=Rahasia untuk alur kerja yang disebut +completion.workflow.job.gitea.ignored=Diterima Gitea, diabaikan saat runtime. completion.workflow.defaultsRun.shell=Shell default untuk langkah-langkah yang dijalankan completion.workflow.defaultsRun.working-directory=Direktori kerja default completion.workflow.concurrency.group=Kunci nama untuk proses yang antri @@ -376,8 +384,23 @@ completion.steps.outcome=Hasil dari langkah yang diselesaikan sebelum kesalahan completion.jobs.outputs=Kumpulan output yang ditentukan untuk pekerjaan itu. completion.jobs.result=Hasil pekerjaan. settings.displayName=Alur Kerja GitHub +settings.gitea.displayName=Gitea settings.language.label=Bahasa: settings.language.system=IDE/default sistem +settings.gitea.title=Akun Gitea +settings.gitea.column.enabled=Aktif +settings.gitea.column.name=Nama +settings.gitea.column.webUrl=URL web +settings.gitea.column.apiUrl=URL API +settings.gitea.column.tokenEnv=Env token +settings.gitea.column.token=Token akses +settings.gitea.add=Tambah Gitea +settings.gitea.remove=Hapus +settings.gitea.setToken=Atur token +settings.gitea.clearToken=Bersihkan token +settings.gitea.noneSelected=Pilih baris Gitea dulu. +settings.gitea.invalidRows=Perbaiki baris Gitea yang tidak valid dulu. Nama, URL web, dan URL API wajib diisi. +settings.gitea.token.prompt=Tempel token Gitea settings.cache.title=Tembolok tindakan settings.cache.column.key=Kunci cache settings.cache.column.name=Nama diff --git a/src/main/resources/messages/GitHubWorkflowBundle_it.properties b/src/main/resources/messages/GitHubWorkflowBundle_it.properties index 8f26b11..a629734 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_it.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_it.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub ha restituito una pagina di errore HTML invece de workflow.run.gutter.stop=Arresta l''esecuzione del flusso di lavoro workflow.run.gutter.stop.text=Arresta l''esecuzione del flusso di lavoro workflow.run.gutter.stop.description=Annulla questa corsa -workflow.run.auth.settings=Impostazioni > Controllo versione > GitHub +workflow.run.auth.settings.github=Impostazioni > Controllo versione > GitHub +workflow.run.auth.settings.gitea=Impostazioni > Controllo versione > Gitea +workflow.run.auth.add=Aggiungi token o account in {0}. workflow.log.command=correre: workflow.log.warning=avvertimento: workflow.log.error=errore: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Autorizzazione sconosciuta [{0}] inspection.workflow.syntax.unknownPermissionValue=Valore dell''autorizzazione sconosciuto [{0}] inspection.workflow.syntax.unknownJobKey=Chiave lavoro sconosciuta [{0}] inspection.workflow.syntax.unknownStepKey=Tasto passaggio sconosciuto [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea supporta qui una sola etichetta runner +inspection.workflow.syntax.giteaExpressionFunction=Gitea documenta qui solo always(): [{0}] inspection.action.reload=Ricarica [{0}] inspection.action.unresolved=Non risolto [{0}]: controlla l''accesso all''account GitHub, le autorizzazioni del repository privato, i limiti di velocità, i riferimenti mancanti o i metadati di azioni/flussi di lavoro mancanti inspection.action.jump=Vai al file [{0}] @@ -215,6 +219,9 @@ completion.context.github=Informazioni sull''esecuzione e sugli eventi del fluss completion.context.gitea=Alias compatibile con Gitea per il contesto Azioni GitHub. completion.context.runner=Informazioni sul corridore che sta eseguendo il lavoro corrente. completion.secret.githubToken=Token creato automaticamente per ogni esecuzione del flusso di lavoro. +completion.secret.giteaToken=Token Gitea creato automaticamente per ogni esecuzione. +completion.env.gitea=Variabile d’ambiente del runner Gitea. +completion.expressionFunction=Funzione di espressione. Mini incantesimo logico. completion.remote.repository=Archivio remoto completion.uses.local.workflow=Flusso di lavoro riutilizzabile locale completion.uses.local.action=Azione locale @@ -317,6 +324,7 @@ completion.workflow.job.services=Contenitori di servizio sidecar completion.workflow.job.uses=Flusso di lavoro riutilizzabile per chiamare completion.workflow.job.with=Input per il flusso di lavoro chiamato completion.workflow.job.secrets=Segreti per il flusso di lavoro chiamato +completion.workflow.job.gitea.ignored=Accettato da Gitea, ignorato a runtime. completion.workflow.defaultsRun.shell=Shell predefinita per i passaggi di esecuzione completion.workflow.defaultsRun.working-directory=Directory di lavoro predefinita completion.workflow.concurrency.group=Blocca il nome per le esecuzioni in coda @@ -376,8 +384,23 @@ completion.steps.outcome=Il risultato di un passaggio completato prima che venga completion.jobs.outputs=L''insieme di output definiti per il lavoro. completion.jobs.result=Il risultato del lavoro. settings.displayName=Flusso di lavoro GitHub +settings.gitea.displayName=Gitea settings.language.label=Lingua: settings.language.system=IDE/impostazione predefinita del sistema +settings.gitea.title=Account Gitea +settings.gitea.column.enabled=Attivo +settings.gitea.column.name=Nome +settings.gitea.column.webUrl=URL web +settings.gitea.column.apiUrl=URL API +settings.gitea.column.tokenEnv=Env token +settings.gitea.column.token=Token accesso +settings.gitea.add=Aggiungi Gitea +settings.gitea.remove=Rimuovi +settings.gitea.setToken=Imposta token +settings.gitea.clearToken=Cancella token +settings.gitea.noneSelected=Seleziona prima una riga Gitea. +settings.gitea.invalidRows=Correggi prima le righe Gitea non valide. Nome, URL web e URL API vogliono testo. +settings.gitea.token.prompt=Incolla token Gitea settings.cache.title=Cache delle azioni settings.cache.column.key=Chiave della cache settings.cache.column.name=Nome diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ja.properties b/src/main/resources/messages/GitHubWorkflowBundle_ja.properties index 0b7923f..e754dfd 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ja.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ja.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub は API データではなく HTML エラーペ workflow.run.gutter.stop=ワークフローの実行を停止する workflow.run.gutter.stop.text=ワークフローの実行を停止する workflow.run.gutter.stop.description=この実行をキャンセルする -workflow.run.auth.settings=設定 > バージョン管理 > GitHub +workflow.run.auth.settings.github=設定 > バージョン管理 > GitHub +workflow.run.auth.settings.gitea=設定 > バージョン管理 > Gitea +workflow.run.auth.add={0} でトークンまたはアカウントを追加。 workflow.log.command=実行: workflow.log.warning=警告: workflow.log.error=エラー: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=不明な権限 [{0}] inspection.workflow.syntax.unknownPermissionValue=不明な権限値 [{0}] inspection.workflow.syntax.unknownJobKey=不明なジョブキー [{0}] inspection.workflow.syntax.unknownStepKey=不明なステップキー [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea はここで runner ラベルを1つだけサポートします +inspection.workflow.syntax.giteaExpressionFunction=Gitea がここで文書化しているのは always() だけです: [{0}] inspection.action.reload=リロード [{0}] inspection.action.unresolved=未解決 [{0}] - GitHub アカウント アクセス、プライベート リポジトリのアクセス許可、レート制限、参照の欠落、またはアクション/ワークフローのメタデータの欠落を確認してください。 inspection.action.jump=ファイル[{0}]へジャンプ @@ -215,6 +219,9 @@ completion.context.github=GitHub コンテキストからのワークフロー completion.context.gitea=GitHub アクション コンテキストの Gitea 互換のエイリアス。 completion.context.runner=現在のジョブを実行しているランナーに関する情報。 completion.secret.githubToken=ワークフロー実行ごとに自動的に作成されるトークン。 +completion.secret.giteaToken=各ワークフロー実行で自動作成される Gitea トークン。 +completion.env.gitea=Gitea runner の環境変数。 +completion.expressionFunction=式関数。小さな論理呪文。 completion.remote.repository=リモートリポジトリ completion.uses.local.workflow=ローカルで再利用可能なワークフロー completion.uses.local.action=ローカルアクション @@ -317,6 +324,7 @@ completion.workflow.job.services=サイドカーサービスコンテナ completion.workflow.job.uses=呼び出すための再利用可能なワークフロー completion.workflow.job.with=呼び出されたワークフローの入力 completion.workflow.job.secrets=呼び出されたワークフローのシークレット +completion.workflow.job.gitea.ignored=Gitea は受け入れますが、実行時は無視します。 completion.workflow.defaultsRun.shell=実行ステップのデフォルトのシェル completion.workflow.defaultsRun.working-directory=デフォルトの作業ディレクトリ completion.workflow.concurrency.group=キューに入れられた実行のロック名 @@ -376,8 +384,23 @@ completion.steps.outcome=エラー時続行が適用される前に完了した completion.jobs.outputs=ジョブに対して定義された出力のセット。 completion.jobs.result=仕事の結果。 settings.displayName=GitHub ワークフロー +settings.gitea.displayName=Gitea settings.language.label=言語: settings.language.system=IDE/システムデフォルト +settings.gitea.title=Gitea アカウント +settings.gitea.column.enabled=有効 +settings.gitea.column.name=名前 +settings.gitea.column.webUrl=Web URL 値 +settings.gitea.column.apiUrl=API URL 値 +settings.gitea.column.tokenEnv=トークン環境変数 +settings.gitea.column.token=アクセス用トークン +settings.gitea.add=Gitea 追加 +settings.gitea.remove=削除 +settings.gitea.setToken=トークン設定 +settings.gitea.clearToken=トークン消去 +settings.gitea.noneSelected=先に Gitea 行を選択。 +settings.gitea.invalidRows=無効な Gitea 行を先に直してください。名前、Web URL、API URL が必要です。 +settings.gitea.token.prompt=Gitea トークンを貼り付け settings.cache.title=アクションキャッシュ settings.cache.column.key=キャッシュキー settings.cache.column.name=名前 diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ko.properties b/src/main/resources/messages/GitHubWorkflowBundle_ko.properties index 3a0389b..e8b8d23 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ko.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ko.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub가 API 데이터 대신 HTML 오류 페이지를 workflow.run.gutter.stop=워크플로 실행 중지 workflow.run.gutter.stop.text=워크플로 실행 중지 workflow.run.gutter.stop.description=이 실행 취소 -workflow.run.auth.settings=설정 > 버전 관리 > GitHub +workflow.run.auth.settings.github=설정 > 버전 관리 > GitHub +workflow.run.auth.settings.gitea=설정 > 버전 관리 > Gitea +workflow.run.auth.add={0}에서 토큰 또는 계정을 추가하세요. workflow.log.command=실행: workflow.log.warning=경고: workflow.log.error=오류: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=알 수 없는 권한 [{0}] inspection.workflow.syntax.unknownPermissionValue=알 수 없는 권한 값 [{0}] inspection.workflow.syntax.unknownJobKey=알 수 없는 작업 키 [{0}] inspection.workflow.syntax.unknownStepKey=알 수 없는 단계 키 [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea는 여기서 runner 레이블 하나만 지원합니다 +inspection.workflow.syntax.giteaExpressionFunction=Gitea는 여기서 always()만 문서화합니다: [{0}] inspection.action.reload=[{0}] 새로고침 inspection.action.unresolved=해결되지 않음 [{0}] - GitHub 계정 액세스, 개인 저장소 권한, 속도 제한, 누락된 참조 또는 누락된 작업/워크플로 메타데이터를 확인하세요. inspection.action.jump=[{0}] 파일로 이동 @@ -215,6 +219,9 @@ completion.context.github=GitHub 컨텍스트의 워크플로 실행 및 이벤 completion.context.gitea=GitHub 작업 컨텍스트에 대한 Gitea 호환 별칭입니다. completion.context.runner=현재 작업을 실행하는 러너에 대한 정보입니다. completion.secret.githubToken=각 워크플로 실행에 대해 자동으로 생성된 토큰입니다. +completion.secret.giteaToken=각 워크플로 실행마다 자동 생성되는 Gitea 토큰입니다. +completion.env.gitea=Gitea runner 환경 변수입니다. +completion.expressionFunction=표현식 함수. 작은 논리 주문. completion.remote.repository=원격 저장소 completion.uses.local.workflow=로컬 재사용 가능한 워크플로우 completion.uses.local.action=지역 활동 @@ -317,6 +324,7 @@ completion.workflow.job.services=사이드카 서비스 컨테이너 completion.workflow.job.uses=호출에 재사용 가능한 워크플로 completion.workflow.job.with=호출된 워크플로에 대한 입력 completion.workflow.job.secrets=호출된 워크플로의 비밀 +completion.workflow.job.gitea.ignored=Gitea는 받아들이지만 실행 시 무시합니다. completion.workflow.defaultsRun.shell=실행 단계의 기본 셸 completion.workflow.defaultsRun.working-directory=기본 작업 디렉터리 completion.workflow.concurrency.group=대기 중인 실행에 대한 잠금 이름 @@ -376,8 +384,23 @@ completion.steps.outcome=오류 시 계속이 적용되기 전 완료된 단계 completion.jobs.outputs=작업에 대해 정의된 출력 세트입니다. completion.jobs.result=작업의 결과입니다. settings.displayName=GitHub 작업 흐름 +settings.gitea.displayName=Gitea settings.language.label=언어: settings.language.system=IDE/시스템 기본값 +settings.gitea.title=Gitea 계정 +settings.gitea.column.enabled=사용 +settings.gitea.column.name=이름 +settings.gitea.column.webUrl=웹 URL +settings.gitea.column.apiUrl=API URL 값 +settings.gitea.column.tokenEnv=토큰 환경변수 +settings.gitea.column.token=접근 토큰 +settings.gitea.add=Gitea 추가 +settings.gitea.remove=제거 +settings.gitea.setToken=토큰 설정 +settings.gitea.clearToken=토큰 지우기 +settings.gitea.noneSelected=먼저 Gitea 행을 선택하세요. +settings.gitea.invalidRows=잘못된 Gitea 행을 먼저 고치세요. 이름, 웹 URL, API URL이 필요합니다. +settings.gitea.token.prompt=Gitea 토큰 붙여넣기 settings.cache.title=액션 캐시 settings.cache.column.key=캐시 키 settings.cache.column.name=이름 diff --git a/src/main/resources/messages/GitHubWorkflowBundle_nl.properties b/src/main/resources/messages/GitHubWorkflowBundle_nl.properties index ece386f..a66adbd 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_nl.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_nl.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub gaf een HTML-foutpagina terug in plaats van API-g workflow.run.gutter.stop=Stop de werkstroomuitvoering workflow.run.gutter.stop.text=Stop de werkstroomuitvoering workflow.run.gutter.stop.description=Annuleer deze uitvoering -workflow.run.auth.settings=Instellingen > Versiebeheer > GitHub +workflow.run.auth.settings.github=Instellingen > Versiebeheer > GitHub +workflow.run.auth.settings.gitea=Instellingen > Versiebeheer > Gitea +workflow.run.auth.add=Voeg een token of account toe in {0}. workflow.log.command=rennen: workflow.log.warning=waarschuwing: workflow.log.error=fout: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Onbekende toestemming [{0}] inspection.workflow.syntax.unknownPermissionValue=Onbekende machtigingswaarde [{0}] inspection.workflow.syntax.unknownJobKey=Onbekende taaksleutel [{0}] inspection.workflow.syntax.unknownStepKey=Onbekende stapsleutel [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea ondersteunt hier één runnerlabel +inspection.workflow.syntax.giteaExpressionFunction=Gitea documenteert hier alleen always(): [{0}] inspection.action.reload=Herladen [{0}] inspection.action.unresolved=Onopgelost [{0}] - controleer GitHub-accounttoegang, machtigingen voor privérepository, snelheidslimieten, ontbrekende referenties of ontbrekende metadata van acties/workflows inspection.action.jump=Ga naar bestand [{0}] @@ -215,6 +219,9 @@ completion.context.github=Workflowuitvoering en gebeurtenisinformatie uit de Git completion.context.gitea=Gitea-compatibele alias voor de GitHub Actions-context. completion.context.runner=Informatie over de hardloper die de huidige taak uitvoert. completion.secret.githubToken=Automatisch aangemaakt token voor elke workflowuitvoering. +completion.secret.giteaToken=Automatisch gemaakt Gitea-token voor elke workflowrun. +completion.env.gitea=Omgevingsvariabele van de Gitea-runner. +completion.expressionFunction=Expressiefunctie. Klein logicaspreukje. completion.remote.repository=Externe opslagplaats completion.uses.local.workflow=Lokale herbruikbare workflow completion.uses.local.action=Lokale actie @@ -317,6 +324,7 @@ completion.workflow.job.services=Zijspan servicecontainers completion.workflow.job.uses=Herbruikbare workflow om te bellen completion.workflow.job.with=Ingangen voor de opgeroepen workflow completion.workflow.job.secrets=Geheimen voor de opgeroepen workflow +completion.workflow.job.gitea.ignored=Geaccepteerd door Gitea, genegeerd tijdens runtime. completion.workflow.defaultsRun.shell=Standaardshell voor uitvoeringsstappen completion.workflow.defaultsRun.working-directory=Standaard werkmap completion.workflow.concurrency.group=Naam van vergrendeling voor uitvoeringen in de wachtrij @@ -376,8 +384,23 @@ completion.steps.outcome=Het resultaat van een voltooide stap vóór doorgaan bi completion.jobs.outputs=De set uitvoer die voor de taak is gedefinieerd. completion.jobs.result=Het resultaat van de klus. settings.displayName=GitHub-workflow +settings.gitea.displayName=Gitea settings.language.label=Taal: settings.language.system=IDE/systeemstandaard +settings.gitea.title=Gitea-accounts +settings.gitea.column.enabled=Aan +settings.gitea.column.name=Naam +settings.gitea.column.webUrl=Web-URL +settings.gitea.column.apiUrl=API-URL adres +settings.gitea.column.tokenEnv=Token-env +settings.gitea.column.token=Toegangstoken +settings.gitea.add=Gitea toevoegen +settings.gitea.remove=Verwijderen +settings.gitea.setToken=Token instellen +settings.gitea.clearToken=Token wissen +settings.gitea.noneSelected=Selecteer eerst een Gitea-rij. +settings.gitea.invalidRows=Repareer eerst ongeldige Gitea-rijen. Naam, web-URL en API-URL hebben tekst nodig. +settings.gitea.token.prompt=Plak Gitea-token settings.cache.title=Actiecache settings.cache.column.key=Cachesleutel settings.cache.column.name=Naam diff --git a/src/main/resources/messages/GitHubWorkflowBundle_pl.properties b/src/main/resources/messages/GitHubWorkflowBundle_pl.properties index a3fbd4d..bc9ccbc 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_pl.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_pl.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub zwrócił stronę błędu HTML zamiast danych API workflow.run.gutter.stop=Zatrzymaj przebieg przepływu pracy workflow.run.gutter.stop.text=Zatrzymaj przebieg przepływu pracy workflow.run.gutter.stop.description=Anuluj ten bieg -workflow.run.auth.settings=Ustawienia > Kontrola wersji > GitHub +workflow.run.auth.settings.github=Ustawienia > Kontrola wersji > GitHub +workflow.run.auth.settings.gitea=Ustawienia > Kontrola wersji > Gitea +workflow.run.auth.add=Dodaj token albo konto w {0}. workflow.log.command=biegnij: workflow.log.warning=ostrzeżenie: workflow.log.error=błąd: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Nieznane uprawnienia [{0}] inspection.workflow.syntax.unknownPermissionValue=Nieznana wartość uprawnienia [{0}] inspection.workflow.syntax.unknownJobKey=Nieznany klucz zadania [{0}] inspection.workflow.syntax.unknownStepKey=Nieznany klucz kroku [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea obsługuje tu jedną etykietę runnera +inspection.workflow.syntax.giteaExpressionFunction=Gitea dokumentuje tu tylko always(): [{0}] inspection.action.reload=Załaduj ponownie [{0}] inspection.action.unresolved=Nierozwiązany [{0}] — sprawdź dostęp do konta GitHub, uprawnienia do prywatnego repozytorium, limity szybkości, brakujące referencje lub brakujące metadane akcji/przepływu pracy inspection.action.jump=Skocz do pliku [{0}] @@ -215,6 +219,9 @@ completion.context.github=Informacje o przebiegu przepływu pracy i zdarzeniach completion.context.gitea=Alias zgodny z Gitea dla kontekstu akcji GitHub. completion.context.runner=Informacja o biegaczu wykonującym bieżące zadanie. completion.secret.githubToken=Automatycznie tworzony token dla każdego uruchomienia przepływu pracy. +completion.secret.giteaToken=Automatycznie utworzony token Gitea dla każdego uruchomienia. +completion.env.gitea=Zmienna środowiskowa runnera Gitea. +completion.expressionFunction=Funkcja wyrażenia. Mały czar logiki. completion.remote.repository=Zdalne repozytorium completion.uses.local.workflow=Lokalny przepływ pracy wielokrotnego użytku completion.uses.local.action=Akcja lokalna @@ -317,6 +324,7 @@ completion.workflow.job.services=Kontenery serwisowe z wózkiem bocznym completion.workflow.job.uses=Przepływ pracy wielokrotnego użytku, do którego można zadzwonić completion.workflow.job.with=Dane wejściowe dla wywoływanego przepływu pracy completion.workflow.job.secrets=Sekrety wywoływanego przepływu pracy +completion.workflow.job.gitea.ignored=Akceptowane przez Gitea, ignorowane w runtime. completion.workflow.defaultsRun.shell=Domyślna powłoka dla kroków uruchamiania completion.workflow.defaultsRun.working-directory=Domyślny katalog roboczy completion.workflow.concurrency.group=Zablokuj nazwę dla uruchomień w kolejce @@ -376,8 +384,23 @@ completion.steps.outcome=Wynik zakończonego kroku przed zastosowaniem błędu k completion.jobs.outputs=Zestaw wyników zdefiniowany dla zadania. completion.jobs.result=Wynik pracy. settings.displayName=Przebieg pracy GitHub +settings.gitea.displayName=Gitea settings.language.label=Język: settings.language.system=Wartość domyślna IDE/systemowa +settings.gitea.title=Konta Gitea +settings.gitea.column.enabled=Włączone +settings.gitea.column.name=Nazwa +settings.gitea.column.webUrl=URL WWW +settings.gitea.column.apiUrl=URL API adres +settings.gitea.column.tokenEnv=Env tokena +settings.gitea.column.token=Token dostępu +settings.gitea.add=Dodaj Gitea +settings.gitea.remove=Usuń +settings.gitea.setToken=Ustaw token +settings.gitea.clearToken=Wyczyść token +settings.gitea.noneSelected=Najpierw wybierz wiersz Gitea. +settings.gitea.invalidRows=Najpierw napraw nieprawidłowe wiersze Gitea. Nazwa, URL WWW i URL API wymagają tekstu. +settings.gitea.token.prompt=Wklej token Gitea settings.cache.title=Pamięć akcji settings.cache.column.key=Klucz pamięci podręcznej settings.cache.column.name=Imię diff --git a/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties b/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties index deaffc5..543b152 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_pt_BR.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub retornou uma página de erro HTML em vez de dados workflow.run.gutter.stop=Interromper a execução do fluxo de trabalho workflow.run.gutter.stop.text=Interromper a execução do fluxo de trabalho workflow.run.gutter.stop.description=Cancelar esta execução -workflow.run.auth.settings=Configurações > Controle de versão > GitHub +workflow.run.auth.settings.github=Configurações > Controle de versão > GitHub +workflow.run.auth.settings.gitea=Configurações > Controle de versão > Gitea +workflow.run.auth.add=Adicione token ou conta em {0}. workflow.log.command=execute: workflow.log.warning=aviso: workflow.log.error=erro: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Permissão desconhecida [{0}] inspection.workflow.syntax.unknownPermissionValue=Valor de permissão desconhecido [{0}] inspection.workflow.syntax.unknownJobKey=Chave de trabalho desconhecida [{0}] inspection.workflow.syntax.unknownStepKey=Chave de etapa desconhecida [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea aceita aqui só um rótulo de runner +inspection.workflow.syntax.giteaExpressionFunction=Gitea documenta aqui só always(): [{0}] inspection.action.reload=Recarregar [{0}] inspection.action.unresolved=Não resolvido [{0}] - verifique o acesso à conta GitHub, permissões de repositório privado, limites de taxa, referências ausentes ou metadados de ação/fluxo de trabalho ausentes inspection.action.jump=Ir para o arquivo [{0}] @@ -215,6 +219,9 @@ completion.context.github=Informações de evento e execução de fluxo de traba completion.context.gitea=Alias compatível com Gitea para o contexto de ações GitHub. completion.context.runner=Informações sobre o executor que está executando o trabalho atual. completion.secret.githubToken=Token criado automaticamente para cada execução de fluxo de trabalho. +completion.secret.giteaToken=Token Gitea criado automaticamente para cada execução. +completion.env.gitea=Variável de ambiente do runner Gitea. +completion.expressionFunction=Função de expressão. Feitiço lógico mini. completion.remote.repository=Repositório remoto completion.uses.local.workflow=Fluxo de trabalho reutilizável local completion.uses.local.action=Ação local @@ -317,6 +324,7 @@ completion.workflow.job.services=Contêineres de serviço sidecar completion.workflow.job.uses=Fluxo de trabalho reutilizável para ligar completion.workflow.job.with=Entradas para fluxo de trabalho chamado completion.workflow.job.secrets=Segredos para o fluxo de trabalho chamado +completion.workflow.job.gitea.ignored=Aceito pelo Gitea, ignorado em runtime. completion.workflow.defaultsRun.shell=Shell padrão para etapas de execução completion.workflow.defaultsRun.working-directory=Diretório de trabalho padrão completion.workflow.concurrency.group=Bloquear nome para execuções na fila @@ -376,8 +384,23 @@ completion.steps.outcome=O resultado de uma etapa concluída antes da aplicaçã completion.jobs.outputs=O conjunto de saídas definidas para o trabalho. completion.jobs.result=O resultado do trabalho. settings.displayName=Fluxo de trabalho GitHub +settings.gitea.displayName=Gitea settings.language.label=Idioma: settings.language.system=IDE/padrão do sistema +settings.gitea.title=Contas Gitea +settings.gitea.column.enabled=Ativo +settings.gitea.column.name=Nome +settings.gitea.column.webUrl=URL web +settings.gitea.column.apiUrl=URL de API +settings.gitea.column.tokenEnv=Env do token +settings.gitea.column.token=Token de acesso +settings.gitea.add=Adicionar Gitea +settings.gitea.remove=Remover +settings.gitea.setToken=Definir token +settings.gitea.clearToken=Limpar token +settings.gitea.noneSelected=Selecione uma linha Gitea primeiro. +settings.gitea.invalidRows=Corrija primeiro as linhas Gitea inválidas. Nome, URL web e URL API precisam de texto. +settings.gitea.token.prompt=Cole o token Gitea settings.cache.title=Cache de ação settings.cache.column.key=Chave de cache settings.cache.column.name=Nome diff --git a/src/main/resources/messages/GitHubWorkflowBundle_ru.properties b/src/main/resources/messages/GitHubWorkflowBundle_ru.properties index 8e7eb95..c744dd2 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_ru.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_ru.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub вернул HTML-страницу ошибки workflow.run.gutter.stop=Остановить выполнение рабочего процесса workflow.run.gutter.stop.text=Остановить выполнение рабочего процесса workflow.run.gutter.stop.description=Отменить этот запуск -workflow.run.auth.settings=Настройки > Контроль версий > GitHub +workflow.run.auth.settings.github=Настройки > Контроль версий > GitHub +workflow.run.auth.settings.gitea=Настройки > Контроль версий > Gitea +workflow.run.auth.add=Добавьте токен или аккаунт в {0}. workflow.log.command=запустить: workflow.log.warning=предупреждение: workflow.log.error=ошибка: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Неизвестное разреш inspection.workflow.syntax.unknownPermissionValue=Неизвестное значение разрешения [{0}] inspection.workflow.syntax.unknownJobKey=Неизвестный ключ задания [{0}] inspection.workflow.syntax.unknownStepKey=Неизвестная клавиша шага [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea поддерживает здесь одну метку раннера +inspection.workflow.syntax.giteaExpressionFunction=Gitea документирует здесь только always(): [{0}] inspection.action.reload=Перезагрузить [{0}] inspection.action.unresolved=Неразрешено [{0}] — проверьте доступ к учетной записи GitHub, разрешения частного репозитория, ограничения скорости, отсутствующие ссылки или отсутствующие метаданные действий/рабочих процессов. inspection.action.jump=Перейти к файлу [{0}] @@ -215,6 +219,9 @@ completion.context.github=Информация о запуске рабочег completion.context.gitea=Совместимый с Gitea псевдоним для контекста действий GitHub. completion.context.runner=Информация о бегуне, выполняющем текущее задание. completion.secret.githubToken=Автоматически создается токен для каждого запуска рабочего процесса. +completion.secret.giteaToken=Автоматический токен Gitea для каждого запуска workflow. +completion.env.gitea=Переменная окружения раннера Gitea. +completion.expressionFunction=Функция выражения. Маленькое заклинание логики. completion.remote.repository=Удаленный репозиторий completion.uses.local.workflow=Локальный многоразовый рабочий процесс completion.uses.local.action=Местное действие @@ -317,6 +324,7 @@ completion.workflow.job.services=Сервисные контейнеры с ко completion.workflow.job.uses=Многоразовый рабочий процесс для вызова completion.workflow.job.with=Входные данные для вызванного рабочего процесса completion.workflow.job.secrets=Секреты вызываемого рабочего процесса +completion.workflow.job.gitea.ignored=Gitea принимает, но игнорирует при выполнении. completion.workflow.defaultsRun.shell=Оболочка по умолчанию для шагов выполнения completion.workflow.defaultsRun.working-directory=Рабочий каталог по умолчанию completion.workflow.concurrency.group=Заблокировать имя для запусков в очереди @@ -376,8 +384,23 @@ completion.steps.outcome=Результат завершенного шага д completion.jobs.outputs=Набор выходных данных, определенных для задания. completion.jobs.result=Результат работы. settings.displayName=GitHub Рабочий процесс +settings.gitea.displayName=Gitea settings.language.label=Язык: settings.language.system=IDE/системное значение по умолчанию +settings.gitea.title=Аккаунты Gitea +settings.gitea.column.enabled=Вкл +settings.gitea.column.name=Имя +settings.gitea.column.webUrl=Web URL адрес +settings.gitea.column.apiUrl=API URL адрес +settings.gitea.column.tokenEnv=Env токена +settings.gitea.column.token=Токен доступа +settings.gitea.add=Добавить Gitea +settings.gitea.remove=Удалить +settings.gitea.setToken=Задать токен +settings.gitea.clearToken=Очистить токен +settings.gitea.noneSelected=Сначала выберите строку Gitea. +settings.gitea.invalidRows=Сначала исправьте неверные строки Gitea. Имя, web URL и API URL должны быть заполнены. +settings.gitea.token.prompt=Вставьте токен Gitea settings.cache.title=Кэш действий settings.cache.column.key=Ключ кэша settings.cache.column.name=Имя diff --git a/src/main/resources/messages/GitHubWorkflowBundle_sv.properties b/src/main/resources/messages/GitHubWorkflowBundle_sv.properties index b78045b..d861b2e 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_sv.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_sv.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub returnerade en HTML-felsida i stället för API-d workflow.run.gutter.stop=Stoppa körningen av arbetsflödet workflow.run.gutter.stop.text=Stoppa körningen av arbetsflödet workflow.run.gutter.stop.description=Avbryt denna körning -workflow.run.auth.settings=Inställningar > Versionskontroll > GitHub +workflow.run.auth.settings.github=Inställningar > Versionskontroll > GitHub +workflow.run.auth.settings.gitea=Inställningar > Versionskontroll > Gitea +workflow.run.auth.add=Lägg till token eller konto i {0}. workflow.log.command=kör: workflow.log.warning=varning: workflow.log.error=fel: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Okänd behörighet [{0}] inspection.workflow.syntax.unknownPermissionValue=Okänt behörighetsvärde [{0}] inspection.workflow.syntax.unknownJobKey=Okänd jobbnyckel [{0}] inspection.workflow.syntax.unknownStepKey=Okänd stegnyckel [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea stöder en runneretikett här +inspection.workflow.syntax.giteaExpressionFunction=Gitea dokumenterar bara always() här: [{0}] inspection.action.reload=Ladda om [{0}] inspection.action.unresolved=Olöst [{0}] - kontrollera GitHub-kontoåtkomst, behörigheter för privata arkiv, hastighetsgränser, saknade refs eller saknade åtgärds-/arbetsflödesmetadata inspection.action.jump=Hoppa till fil [{0}] @@ -215,6 +219,9 @@ completion.context.github=Arbetsflödeskörning och händelseinformation från G completion.context.gitea=Gitea-kompatibelt alias för GitHub Actions-kontexten. completion.context.runner=Information om löparen som utför det aktuella jobbet. completion.secret.githubToken=Automatiskt skapad token för varje arbetsflödeskörning. +completion.secret.giteaToken=Automatiskt skapat Gitea-token för varje körning. +completion.env.gitea=Miljövariabel för Gitea-runnern. +completion.expressionFunction=Uttrycksfunktion. Liten logikformel. completion.remote.repository=Fjärrförvar completion.uses.local.workflow=Lokalt återanvändbart arbetsflöde completion.uses.local.action=Lokal handling @@ -317,6 +324,7 @@ completion.workflow.job.services=Sidovagnsservicecontainrar completion.workflow.job.uses=Återanvändbart arbetsflöde att ringa completion.workflow.job.with=Ingångar för anropat arbetsflöde completion.workflow.job.secrets=Hemligheter för kallat arbetsflöde +completion.workflow.job.gitea.ignored=Accepteras av Gitea, ignoreras vid körning. completion.workflow.defaultsRun.shell=Standardskal för körsteg completion.workflow.defaultsRun.working-directory=Standard arbetskatalog completion.workflow.concurrency.group=Låsnamn för körningar i kö @@ -376,8 +384,23 @@ completion.steps.outcome=Resultatet av ett avslutat steg innan fortsätt-på-fel completion.jobs.outputs=Uppsättningen utgångar som definierats för jobbet. completion.jobs.result=Resultatet av jobbet. settings.displayName=GitHub arbetsflöde +settings.gitea.displayName=Gitea settings.language.label=Språk: settings.language.system=IDE/systemstandard +settings.gitea.title=Gitea-konton +settings.gitea.column.enabled=På +settings.gitea.column.name=Namn +settings.gitea.column.webUrl=Webbadress +settings.gitea.column.apiUrl=API-adress +settings.gitea.column.tokenEnv=Token-env +settings.gitea.column.token=Åtkomsttoken +settings.gitea.add=Lägg till Gitea +settings.gitea.remove=Ta bort +settings.gitea.setToken=Sätt token +settings.gitea.clearToken=Rensa token +settings.gitea.noneSelected=Välj en Gitea-rad först. +settings.gitea.invalidRows=Fixa ogiltiga Gitea-rader först. Namn, webbadress och API-adress behöver text. +settings.gitea.token.prompt=Klistra in Gitea-token settings.cache.title=Åtgärdscache settings.cache.column.key=Cache-nyckel settings.cache.column.name=Namn diff --git a/src/main/resources/messages/GitHubWorkflowBundle_th.properties b/src/main/resources/messages/GitHubWorkflowBundle_th.properties index f55aed7..467f2f5 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_th.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_th.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub ส่งคืนหน้าแสดงข้ workflow.run.gutter.stop=หยุดการรันเวิร์กโฟลว์ workflow.run.gutter.stop.text=หยุดการรันเวิร์กโฟลว์ workflow.run.gutter.stop.description=ยกเลิกการวิ่งครั้งนี้ -workflow.run.auth.settings=การตั้งค่า > การควบคุมเวอร์ชัน > GitHub +workflow.run.auth.settings.github=ตั้งค่า > ควบคุมเวอร์ชัน > GitHub +workflow.run.auth.settings.gitea=ตั้งค่า > ควบคุมเวอร์ชัน > Gitea +workflow.run.auth.add=เพิ่มโทเคนหรือบัญชีใน {0}. workflow.log.command=วิ่ง: workflow.log.warning=คำเตือน: workflow.log.error=ข้อผิดพลาด: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=สิทธิ์ที่ไม inspection.workflow.syntax.unknownPermissionValue=ค่าสิทธิ์ที่ไม่รู้จัก [{0}] inspection.workflow.syntax.unknownJobKey=รหัสงานที่ไม่รู้จัก [{0}] inspection.workflow.syntax.unknownStepKey=รหัสขั้นตอนที่ไม่รู้จัก [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea รองรับป้าย runner เดียวที่นี่ +inspection.workflow.syntax.giteaExpressionFunction=Gitea ระบุไว้ที่นี่เฉพาะ always(): [{0}] inspection.action.reload=โหลดซ้ำ [{0}] inspection.action.unresolved=ยังไม่ได้รับการแก้ไข [{0}] - ตรวจสอบการเข้าถึงบัญชี GitHub, สิทธิ์ของพื้นที่เก็บข้อมูลส่วนตัว, ขีดจำกัดอัตรา, ข้อมูลอ้างอิงหายไป หรือข้อมูลเมตาการดำเนินการ/เวิร์กโฟลว์หายไป inspection.action.jump=ข้ามไปที่ไฟล์ [{0}] @@ -215,6 +219,9 @@ completion.context.github=ข้อมูลการรันเวิร์ก completion.context.gitea=นามแฝงที่เข้ากันได้กับ Gitea สำหรับบริบทการดำเนินการ GitHub completion.context.runner=ข้อมูลเกี่ยวกับนักวิ่งที่ดำเนินงานปัจจุบัน completion.secret.githubToken=โทเค็นที่สร้างขึ้นโดยอัตโนมัติสำหรับการรันเวิร์กโฟลว์แต่ละครั้ง +completion.secret.giteaToken=โทเค็น Gitea ที่สร้างอัตโนมัติสำหรับทุก workflow run. +completion.env.gitea=ตัวแปรสภาพแวดล้อมของ Gitea runner. +completion.expressionFunction=ฟังก์ชันนิพจน์ เวทตรรกะจิ๋ว. completion.remote.repository=พื้นที่เก็บข้อมูลระยะไกล completion.uses.local.workflow=เวิร์กโฟลว์ที่ใช้ซ้ำได้ในท้องถิ่น completion.uses.local.action=การกระทำในท้องถิ่น @@ -317,6 +324,7 @@ completion.workflow.job.services=ตู้คอนเทนเนอร์บ completion.workflow.job.uses=เวิร์กโฟลว์ที่ใช้ซ้ำได้ในการโทร completion.workflow.job.with=อินพุตสำหรับเวิร์กโฟลว์ที่เรียกว่า completion.workflow.job.secrets=ข้อมูลลับสำหรับเวิร์กโฟลว์ที่เรียกว่า +completion.workflow.job.gitea.ignored=Gitea รับไว้ แต่ไม่ใช้ตอน runtime. completion.workflow.defaultsRun.shell=เชลล์ดีฟอลต์สำหรับขั้นตอนการรัน completion.workflow.defaultsRun.working-directory=ไดเร็กทอรีการทำงานเริ่มต้น completion.workflow.concurrency.group=ล็อคชื่อสำหรับการรันที่อยู่ในคิว @@ -376,8 +384,23 @@ completion.steps.outcome=ผลลัพธ์ของขั้นตอนท completion.jobs.outputs=ชุดเอาต์พุตที่กำหนดไว้สำหรับงาน completion.jobs.result=ผลของงาน. settings.displayName=เวิร์กโฟลว์ GitHub +settings.gitea.displayName=Gitea settings.language.label=ภาษา: settings.language.system=IDE/ค่าเริ่มต้นของระบบ +settings.gitea.title=บัญชี Gitea +settings.gitea.column.enabled=เปิด +settings.gitea.column.name=ชื่อ +settings.gitea.column.webUrl=URL เว็บ +settings.gitea.column.apiUrl=URL API +settings.gitea.column.tokenEnv=env โทเคน +settings.gitea.column.token=โทเคนเข้าถึง +settings.gitea.add=เพิ่ม Gitea +settings.gitea.remove=ลบ +settings.gitea.setToken=ตั้งโทเคน +settings.gitea.clearToken=ล้างโทเคน +settings.gitea.noneSelected=เลือกแถว Gitea ก่อน. +settings.gitea.invalidRows=แก้แถว Gitea ที่ไม่ถูกต้องก่อน ชื่อ, URL เว็บ และ URL API ต้องมีข้อความ +settings.gitea.token.prompt=วางโทเคน Gitea settings.cache.title=แคชการดำเนินการ settings.cache.column.key=รหัสแคช settings.cache.column.name=ชื่อ diff --git a/src/main/resources/messages/GitHubWorkflowBundle_tr.properties b/src/main/resources/messages/GitHubWorkflowBundle_tr.properties index 82f7b9a..16c9813 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_tr.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_tr.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub, API verileri yerine HTML hata sayfası döndürd workflow.run.gutter.stop=İş akışı çalışmasını durdur workflow.run.gutter.stop.text=İş akışı çalışmasını durdur workflow.run.gutter.stop.description=Bu çalıştırmayı iptal et -workflow.run.auth.settings=Ayarlar > Sürüm Denetimi > GitHub +workflow.run.auth.settings.github=Ayarlar > Sürüm Kontrolü > GitHub +workflow.run.auth.settings.gitea=Ayarlar > Sürüm Kontrolü > Gitea +workflow.run.auth.add={0} içinde token veya hesap ekleyin. workflow.log.command=koş: workflow.log.warning=uyarı: workflow.log.error=hata: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Bilinmeyen izin [{0}] inspection.workflow.syntax.unknownPermissionValue=Bilinmeyen izin değeri [{0}] inspection.workflow.syntax.unknownJobKey=Bilinmeyen iş anahtarı [{0}] inspection.workflow.syntax.unknownStepKey=Bilinmeyen adım anahtarı [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea burada tek runner etiketi destekler +inspection.workflow.syntax.giteaExpressionFunction=Gitea burada yalnız always() belgeler: [{0}] inspection.action.reload=[{0}]''i yeniden yükle inspection.action.unresolved=Çözümlenmemiş [{0}] - GitHub hesap erişimini, özel depo izinlerini, hız sınırlarını, eksik referansları veya eksik eylem/iş akışı meta verilerini kontrol edin inspection.action.jump=[{0}] dosyasına atla @@ -215,6 +219,9 @@ completion.context.github=GitHub bağlamından iş akışı çalıştırması ve completion.context.gitea=GitHub Eylemleri bağlamı için Gitea uyumlu takma ad. completion.context.runner=Geçerli işi yürüten koşucu hakkında bilgi. completion.secret.githubToken=Her iş akışı çalıştırması için otomatik olarak oluşturulan belirteç. +completion.secret.giteaToken=Her workflow çalışması için otomatik Gitea tokenı. +completion.env.gitea=Gitea runner ortam değişkeni. +completion.expressionFunction=İfade fonksiyonu. Küçük mantık büyüsü. completion.remote.repository=Uzak depo completion.uses.local.workflow=Yerel yeniden kullanılabilir iş akışı completion.uses.local.action=Yerel eylem @@ -317,6 +324,7 @@ completion.workflow.job.services=Sepet servis konteynerleri completion.workflow.job.uses=Aramak için yeniden kullanılabilir iş akışı completion.workflow.job.with=İş akışı adı verilen girişler completion.workflow.job.secrets=İş akışının sırları +completion.workflow.job.gitea.ignored=Gitea kabul eder, çalışma anında yok sayar. completion.workflow.defaultsRun.shell=Çalıştırma adımları için varsayılan kabuk completion.workflow.defaultsRun.working-directory=Varsayılan çalışma dizini completion.workflow.concurrency.group=Sıraya alınmış çalıştırmalar için kilit adı @@ -376,8 +384,23 @@ completion.steps.outcome=Hata durumunda devam etmeden önce tamamlanan bir adım completion.jobs.outputs=İş için tanımlanan çıktılar kümesi. completion.jobs.result=İşin sonucu. settings.displayName=GitHub İş Akışı +settings.gitea.displayName=Gitea settings.language.label=Dil: settings.language.system=IDE/sistem varsayılanı +settings.gitea.title=Gitea hesapları +settings.gitea.column.enabled=Açık +settings.gitea.column.name=Ad +settings.gitea.column.webUrl=Web URL adresi +settings.gitea.column.apiUrl=API URL adresi +settings.gitea.column.tokenEnv=Token ortamı +settings.gitea.column.token=Erişim tokeni +settings.gitea.add=Gitea ekle +settings.gitea.remove=Kaldır +settings.gitea.setToken=Token ayarla +settings.gitea.clearToken=Token temizle +settings.gitea.noneSelected=Önce bir Gitea satırı seçin. +settings.gitea.invalidRows=Önce geçersiz Gitea satırlarını düzelt. Ad, web URL ve API URL metin ister. +settings.gitea.token.prompt=Gitea tokenini yapıştır settings.cache.title=Eylem önbelleği settings.cache.column.key=Önbellek anahtarı settings.cache.column.name=İsim diff --git a/src/main/resources/messages/GitHubWorkflowBundle_uk.properties b/src/main/resources/messages/GitHubWorkflowBundle_uk.properties index 0587461..2cbedb9 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_uk.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_uk.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub повернув HTML-сторінку помил workflow.run.gutter.stop=Зупинити запуск робочого процесу workflow.run.gutter.stop.text=Зупинити запуск робочого процесу workflow.run.gutter.stop.description=Скасувати цей запуск -workflow.run.auth.settings=Налаштування > Контроль версій > GitHub +workflow.run.auth.settings.github=Налаштування > Контроль версій > GitHub +workflow.run.auth.settings.gitea=Налаштування > Контроль версій > Gitea +workflow.run.auth.add=Додайте токен або обліковку в {0}. workflow.log.command=запустити: workflow.log.warning=попередження: workflow.log.error=помилка: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Невідомий дозвіл [{0 inspection.workflow.syntax.unknownPermissionValue=Невідоме значення дозволу [{0}] inspection.workflow.syntax.unknownJobKey=Невідомий ключ завдання [{0}] inspection.workflow.syntax.unknownStepKey=Невідома клавіша кроку [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea підтримує тут одну мітку runner +inspection.workflow.syntax.giteaExpressionFunction=Gitea документує тут лише always(): [{0}] inspection.action.reload=Перезавантажити [{0}] inspection.action.unresolved=Невирішено [{0}] — перевірте доступ до облікового запису GitHub, дозволи приватного сховища, обмеження швидкості, відсутні посилання або відсутні метадані дії/робочого процесу inspection.action.jump=Перейти до файлу [{0}] @@ -215,6 +219,9 @@ completion.context.github=Запуск робочого процесу та ін completion.context.gitea=Gitea-сумісний псевдонім для контексту дій GitHub. completion.context.runner=Інформація про бігуна, який виконує поточне завдання. completion.secret.githubToken=Автоматично створений маркер для кожного запуску робочого процесу. +completion.secret.giteaToken=Автоматично створений токен Gitea для кожного запуску. +completion.env.gitea=Змінна середовища runner Gitea. +completion.expressionFunction=Функція виразу. Маленьке закляття логіки. completion.remote.repository=Віддалений репозиторій completion.uses.local.workflow=Локальний багаторазовий робочий процес completion.uses.local.action=Місцева дія @@ -317,6 +324,7 @@ completion.workflow.job.services=Сервісні контейнери з кол completion.workflow.job.uses=Багаторазовий робочий процес для виклику completion.workflow.job.with=Вхідні дані для викликаного робочого процесу completion.workflow.job.secrets=Секрети робочого процесу +completion.workflow.job.gitea.ignored=Gitea приймає, під час виконання ігнорує. completion.workflow.defaultsRun.shell=Стандартна оболонка для виконання кроків completion.workflow.defaultsRun.working-directory=Робочий каталог за замовчуванням completion.workflow.concurrency.group=Назва блокування для виконання в черзі @@ -376,8 +384,23 @@ completion.steps.outcome=Результат завершеного кроку д completion.jobs.outputs=Набір результатів, визначених для завдання. completion.jobs.result=Результат роботи. settings.displayName=Робочий процес GitHub +settings.gitea.displayName=Gitea settings.language.label=мова: settings.language.system=IDE/система за замовчуванням +settings.gitea.title=Обліковки Gitea +settings.gitea.column.enabled=Увімк +settings.gitea.column.name=Назва +settings.gitea.column.webUrl=Web URL адреса +settings.gitea.column.apiUrl=API URL адреса +settings.gitea.column.tokenEnv=Env токена +settings.gitea.column.token=Токен доступу +settings.gitea.add=Додати Gitea +settings.gitea.remove=Вилучити +settings.gitea.setToken=Задати токен +settings.gitea.clearToken=Очистити токен +settings.gitea.noneSelected=Спершу виберіть рядок Gitea. +settings.gitea.invalidRows=Спершу виправте хибні рядки Gitea. Назва, web URL і API URL мають бути заповнені. +settings.gitea.token.prompt=Вставте токен Gitea settings.cache.title=Кеш дій settings.cache.column.key=Ключ кешу settings.cache.column.name=Ім''я diff --git a/src/main/resources/messages/GitHubWorkflowBundle_vi.properties b/src/main/resources/messages/GitHubWorkflowBundle_vi.properties index 0f6c0ca..09c35e8 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_vi.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_vi.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub trả về trang lỗi HTML thay vì dữ liệu workflow.run.gutter.stop=Dừng chạy quy trình công việc workflow.run.gutter.stop.text=Dừng chạy quy trình công việc workflow.run.gutter.stop.description=Hủy lần chạy này -workflow.run.auth.settings=Cài đặt > Quản lý phiên bản > GitHub +workflow.run.auth.settings.github=Cài đặt > Quản lý phiên bản > GitHub +workflow.run.auth.settings.gitea=Cài đặt > Quản lý phiên bản > Gitea +workflow.run.auth.add=Thêm token hoặc tài khoản trong {0}. workflow.log.command=chạy: workflow.log.warning=cảnh báo: workflow.log.error=lỗi: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=Quyền không xác định [{0}] inspection.workflow.syntax.unknownPermissionValue=Giá trị quyền không xác định [{0}] inspection.workflow.syntax.unknownJobKey=Mã công việc không xác định [{0}] inspection.workflow.syntax.unknownStepKey=Khóa bước không xác định [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea chỉ hỗ trợ một nhãn runner ở đây +inspection.workflow.syntax.giteaExpressionFunction=Gitea chỉ ghi tài liệu always() ở đây: [{0}] inspection.action.reload=Tải lại [{0}] inspection.action.unresolved=[{0}] chưa được giải quyết - kiểm tra quyền truy cập tài khoản GitHub, quyền của kho lưu trữ riêng, giới hạn tốc độ, thiếu giới thiệu hoặc thiếu siêu dữ liệu hành động/quy trình công việc inspection.action.jump=Chuyển tới tập tin [{0}] @@ -215,6 +219,9 @@ completion.context.github=Thông tin sự kiện và hoạt động của quy tr completion.context.gitea=Bí danh tương thích Gitea cho bối cảnh Hành động GitHub. completion.context.runner=Thông tin về người chạy đang thực hiện công việc hiện tại. completion.secret.githubToken=Mã thông báo được tạo tự động cho mỗi lần chạy quy trình công việc. +completion.secret.giteaToken=Token Gitea tự tạo cho mỗi lần chạy workflow. +completion.env.gitea=Biến môi trường của runner Gitea. +completion.expressionFunction=Hàm biểu thức. Bùa logic tí hon. completion.remote.repository=Kho lưu trữ từ xa completion.uses.local.workflow=Quy trình làm việc có thể tái sử dụng cục bộ completion.uses.local.action=Hành động cục bộ @@ -317,6 +324,7 @@ completion.workflow.job.services=Thùng dịch vụ sidecar completion.workflow.job.uses=Quy trình làm việc có thể tái sử dụng để gọi completion.workflow.job.with=Đầu vào cho quy trình công việc được gọi completion.workflow.job.secrets=Bí mật cho quy trình làm việc được gọi là +completion.workflow.job.gitea.ignored=Gitea chấp nhận, runtime bỏ qua. completion.workflow.defaultsRun.shell=Shell mặc định cho các bước chạy completion.workflow.defaultsRun.working-directory=Thư mục làm việc mặc định completion.workflow.concurrency.group=Khóa tên cho các lượt chạy xếp hàng @@ -376,8 +384,23 @@ completion.steps.outcome=Kết quả của một bước đã hoàn thành trư completion.jobs.outputs=Tập hợp các đầu ra được xác định cho công việc. completion.jobs.result=Kết quả của công việc. settings.displayName=Quy trình làm việc của GitHub +settings.gitea.displayName=Gitea settings.language.label=Ngôn ngữ: settings.language.system=IDE/mặc định hệ thống +settings.gitea.title=Tài khoản Gitea +settings.gitea.column.enabled=Bật +settings.gitea.column.name=Tên +settings.gitea.column.webUrl=URL web +settings.gitea.column.apiUrl=URL API +settings.gitea.column.tokenEnv=Env token +settings.gitea.column.token=Token truy cập +settings.gitea.add=Thêm Gitea +settings.gitea.remove=Xóa +settings.gitea.setToken=Đặt token +settings.gitea.clearToken=Xóa token +settings.gitea.noneSelected=Chọn một dòng Gitea trước. +settings.gitea.invalidRows=Sửa các dòng Gitea sai trước. Tên, URL web và URL API cần có chữ. +settings.gitea.token.prompt=Dán token Gitea settings.cache.title=Bộ đệm hành động settings.cache.column.key=Khóa bộ đệm settings.cache.column.name=Tên diff --git a/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties b/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties index 4c4d1ec..27cc1c0 100644 --- a/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties +++ b/src/main/resources/messages/GitHubWorkflowBundle_zh_CN.properties @@ -29,7 +29,9 @@ workflow.run.error.html=GitHub 返回了 HTML 错误页面,而不是 API 数 workflow.run.gutter.stop=停止工作流程运行 workflow.run.gutter.stop.text=停止工作流程运行 workflow.run.gutter.stop.description=取消本次运行 -workflow.run.auth.settings=设置 > 版本控制 > GitHub +workflow.run.auth.settings.github=设置 > 版本控制 > GitHub +workflow.run.auth.settings.gitea=设置 > 版本控制 > Gitea +workflow.run.auth.add=在 {0} 添加令牌或账户。 workflow.log.command=运行: workflow.log.warning=警告: workflow.log.error=错误: @@ -85,6 +87,8 @@ inspection.workflow.syntax.unknownPermission=未知权限 [{0}] inspection.workflow.syntax.unknownPermissionValue=未知的权限值 [{0}] inspection.workflow.syntax.unknownJobKey=未知作业密钥 [{0}] inspection.workflow.syntax.unknownStepKey=未知步骤键 [{0}] +inspection.workflow.syntax.giteaRunsOnSingleLabel=Gitea 此处只支持一个 runner 标签 +inspection.workflow.syntax.giteaExpressionFunction=Gitea 此处仅记录 always():[{0}] inspection.action.reload=重新加载[{0}] inspection.action.unresolved=未解决的 [{0}] - 检查 GitHub 帐户访问、私有存储库权限、速率限制、缺少引用或缺少操作/工作流元数据 inspection.action.jump=跳转到文件 [{0}] @@ -215,6 +219,9 @@ completion.context.github=来自 GitHub 上下文的工作流运行和事件信 completion.context.gitea=GitHub 操作上下文的 Gitea 兼容别名。 completion.context.runner=有关执行当前作业的运行程序的信息。 completion.secret.githubToken=为每个工作流程运行自动创建令牌。 +completion.secret.giteaToken=每次工作流运行自动创建的 Gitea 令牌。 +completion.env.gitea=Gitea runner 环境变量。 +completion.expressionFunction=表达式函数。小型逻辑法术。 completion.remote.repository=远程存储库 completion.uses.local.workflow=本地可重用工作流程 completion.uses.local.action=当地行动 @@ -317,6 +324,7 @@ completion.workflow.job.services=边车服务容器 completion.workflow.job.uses=可重用的工作流程调用 completion.workflow.job.with=被调用工作流程的输入 completion.workflow.job.secrets=被调用工作流程的秘密 +completion.workflow.job.gitea.ignored=Gitea 接受,但运行时忽略。 completion.workflow.defaultsRun.shell=运行步骤的默认 shell completion.workflow.defaultsRun.working-directory=默认工作目录 completion.workflow.concurrency.group=排队运行的锁定名称 @@ -376,8 +384,23 @@ completion.steps.outcome=应用错误继续之前已完成步骤的结果。 completion.jobs.outputs=为作业定义的输出集。 completion.jobs.result=工作的结果。 settings.displayName=GitHub 工作流程 +settings.gitea.displayName=Gitea settings.language.label=语言: settings.language.system=IDE/系统默认 +settings.gitea.title=Gitea 账户 +settings.gitea.column.enabled=启用 +settings.gitea.column.name=名称 +settings.gitea.column.webUrl=网页 URL +settings.gitea.column.apiUrl=API URL 地址 +settings.gitea.column.tokenEnv=令牌环境变量 +settings.gitea.column.token=访问令牌 +settings.gitea.add=添加 Gitea +settings.gitea.remove=移除 +settings.gitea.setToken=设置令牌 +settings.gitea.clearToken=清除令牌 +settings.gitea.noneSelected=先选择一个 Gitea 行。 +settings.gitea.invalidRows=先修好无效的 Gitea 行。名称、网页 URL 和 API URL 都要有内容。 +settings.gitea.token.prompt=粘贴 Gitea 令牌 settings.cache.title=动作缓存 settings.cache.column.key=缓存键 settings.cache.column.name=名称 diff --git a/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java b/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java index df08958..d65146c 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java @@ -2,6 +2,11 @@ import com.github.yunabraska.githubworkflow.run.WorkflowRunConfiguration; +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.settings.GitHubWorkflowSettingsConfigurable; +import com.github.yunabraska.githubworkflow.settings.GiteaSettingsConfigurable; + import com.github.yunabraska.githubworkflow.model.GitHubSchemaProvider; import com.github.yunabraska.githubworkflow.state.GitHubActionCache; @@ -18,9 +23,18 @@ import com.intellij.execution.configurations.ConfigurationTypeUtil; import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import javax.swing.AbstractButton; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JTable; +import javax.swing.border.TitledBorder; +import java.awt.Component; +import java.awt.Container; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -125,10 +139,77 @@ public void testSettingsConfigurableUsesLocalizedPluginXmlKey() throws IOExcepti assertThat(pluginXml) .contains("key=\"settings.displayName\"") + .contains("key=\"settings.gitea.displayName\"") + .contains("id=\"github.workflow.gitea.settings\"") + .contains(" previousServers = remoteSettings.customServers(); + final GiteaSettingsConfigurable configurable = new GiteaSettingsConfigurable(); + try { + remoteSettings.setCustomServers(List.of()); + final JComponent component = configurable.createComponent(); + findButton(component, GitHubWorkflowBundle.message("settings.gitea.add")).doClick(); + final JTable table = findTable(component); + + table.setValueAt("", 0, 3); + + assertThat(configurable.isModified()).isTrue(); + } finally { + configurable.disposeUIResources(); + remoteSettings.setCustomServers(previousServers); + } + } + public void testPackagedSchemasArePresentAndNonEmpty() throws IOException { final Path directory = Path.of(System.getProperty("user.dir"), "src", "main", "resources", "schemas"); @@ -141,4 +222,67 @@ public void testPackagedSchemasArePresentAndNonEmpty() throws IOException { .contains("\"$id\""); } } + + private static void selectComboItem(final Component root, final String text) { + for (final Component component : components(root)) { + if (component instanceof JComboBox comboBox) { + for (int index = 0; index < comboBox.getItemCount(); index++) { + if (text.equals(String.valueOf(comboBox.getItemAt(index)))) { + comboBox.setSelectedIndex(index); + return; + } + } + } + } + throw new AssertionError("Combo item not found: " + text); + } + + private static AbstractButton findButton(final Component root, final String text) { + for (final Component component : components(root)) { + if (component instanceof AbstractButton button && text.equals(button.getText())) { + return button; + } + } + throw new AssertionError("Button not found: " + text); + } + + private static JTable findTable(final Component root) { + for (final Component component : components(root)) { + if (component instanceof JTable table) { + return table; + } + } + throw new AssertionError("Table not found"); + } + + private static List visibleTexts(final Component root) { + final List result = new ArrayList<>(); + for (final Component component : components(root)) { + if (component instanceof JLabel label && label.getText() != null && !label.getText().isBlank()) { + result.add(label.getText()); + } + if (component instanceof AbstractButton button && button.getText() != null && !button.getText().isBlank()) { + result.add(button.getText()); + } + if (component instanceof JComponent swing && swing.getBorder() instanceof TitledBorder border && border.getTitle() != null) { + result.add(border.getTitle()); + } + } + return result; + } + + private static List components(final Component root) { + final List result = new ArrayList<>(); + collectComponents(root, result); + return result; + } + + private static void collectComponents(final Component component, final List result) { + result.add(component); + if (component instanceof Container container) { + for (final Component child : container.getComponents()) { + collectComponents(child, result); + } + } + } } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/git/GiteaDockerIntegrationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/git/GiteaDockerIntegrationTest.java new file mode 100644 index 0000000..511467d --- /dev/null +++ b/src/test/java/com/github/yunabraska/githubworkflow/git/GiteaDockerIntegrationTest.java @@ -0,0 +1,479 @@ +package com.github.yunabraska.githubworkflow.git; + +import com.github.yunabraska.githubworkflow.model.GitHubAction; +import com.github.yunabraska.githubworkflow.run.WorkflowRun; +import com.google.gson.JsonObject; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; + +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.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GiteaDockerIntegrationTest extends BasePlatformTestCase { + + private static final String TEST_SWITCH = "GITEA_DOCKER_TEST"; + private static final String IMAGE = Optional.ofNullable(System.getenv("GITEA_IMAGE")) + .filter(value -> !value.isBlank()) + .orElse("docker.gitea.com/gitea:1.26.2-rootless"); + private static final String RUNNER_IMAGE = Optional.ofNullable(System.getenv("GITEA_RUNNER_IMAGE")) + .filter(value -> !value.isBlank()) + .orElse("docker.io/gitea/act_runner:0.6.1"); + private static final List DOCKER_ENV_NAMES = List.of("GITEA_DOCKER_BIN", "DOCKER_BIN", "DOCKER_CLI"); + private static final HttpClient CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + + @Override + protected void tearDown() throws Exception { + try { + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of()); + } finally { + super.tearDown(); + } + } + + public void testGiteaApiV1ResolvesActionsAndWorkflowsFromEmbeddedContainer() throws Exception { + if ("false".equalsIgnoreCase(System.getenv(TEST_SWITCH))) { + return; + } + try (GiteaContainer gitea = GiteaContainer.start()) { + final String token = gitea.createAdminToken(); + gitea.createRepository(token, "action-box"); + gitea.createFile(token, "action-box", "action.yml", """ + name: Action Box + inputs: + flavor: + description: Test flavor + outputs: + artifact: + description: Test artifact + runs: + using: composite + steps: + - run: echo ok + shell: sh + """); + gitea.createFile(token, "action-box", ".gitea/workflows/reuse.yml", """ + name: Reuse + on: + workflow_call: + inputs: + config: + type: string + jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(RemoteActionProviders.Server.gitea( + "Embedded Gitea", + gitea.webUrl(), + gitea.apiUrl(), + "", + true + ))); + + final GitHubAction action = GitHubAction.createGithubAction(false, gitea.webUrl() + "/test/action-box@main", "gitea-action").resolve(); + final String workflowUses = gitea.webUrl() + "/test/action-box/.gitea/workflows/reuse.yml@main"; + final GitHubAction workflow = GitHubAction.createGithubAction(false, workflowUses, "gitea-workflow").resolve(); + + assertThat(action.isResolved()).isTrue(); + assertThat(action.isAction()).isTrue(); + assertThat(action.freshInputs()).containsKey("flavor"); + assertThat(action.freshOutputs()).containsKey("artifact"); + assertThat(action.remoteRefs()).contains("main"); + assertThat(workflow.isResolved()).isTrue(); + assertThat(workflow.isAction()).isFalse(); + assertThat(workflow.freshInputs()).containsKey("config"); + assertThat(workflow.githubUrl()).isEqualTo(gitea.webUrl() + "/test/action-box/blob/main/.gitea/workflows/reuse.yml"); + } + } + + public void testGiteaWorkflowDispatchRunsJobAndDownloadsLogsWithActRunner() throws Exception { + if ("false".equalsIgnoreCase(System.getenv(TEST_SWITCH))) { + return; + } + try (GiteaContainer gitea = GiteaContainer.start()) { + final String token = gitea.createAdminToken(); + gitea.createRepository(token, "runner-smoke"); + gitea.createFile(token, "runner-smoke", ".gitea/workflows/smoke.yml", """ + name: Runner Smoke + on: + workflow_dispatch: + jobs: + smoke: + runs-on: ubuntu-latest + steps: + - name: Marker + shell: sh + run: echo plugin-gitea-runner-smoke + """); + try (RunnerContainer ignored = gitea.startRunner()) { + final RemoteActionProviders.Server server = RemoteActionProviders.Server.gitea( + "Embedded Gitea", + gitea.webUrl(), + gitea.apiUrl(), + "", + true + ); + RemoteActionProviders.Settings.getInstance() + .setCustomServers(List.of(server)) + .setGiteaToken(server, token); + final WorkflowRun workflowRun = new WorkflowRun(); + final WorkflowRun.Request request = new WorkflowRun.Request( + gitea.apiUrl(), + "test", + "runner-smoke", + ".gitea/workflows/smoke.yml", + "main", + Map.of(), + "GITEA_TOKEN" + ); + + final WorkflowRun.DispatchResult dispatch = workflowRun.dispatch(request); + final long runId = dispatch.runId() >= 0 + ? dispatch.runId() + : workflowRun.latestRun(request).orElseThrow().runId(); + final WorkflowRun.RunStatus completed = waitForCompletedRun(workflowRun, request, runId); + final List jobs = workflowRun.jobs(request, runId); + final String logs = workflowRun.jobLogs(request, jobs.getFirst().id()); + + assertThat(completed.conclusion()).isEqualTo("success"); + assertThat(jobs).hasSize(1); + assertThat(jobs.getFirst().name()).isEqualTo("smoke"); + assertThat(logs).contains("plugin-gitea-runner-smoke"); + } + } + } + + private static WorkflowRun.RunStatus waitForCompletedRun( + final WorkflowRun workflowRun, + final WorkflowRun.Request request, + final long runId + ) throws Exception { + for (int attempt = 0; attempt < 90; attempt++) { + final WorkflowRun.RunStatus status = workflowRun.status(request, runId); + if (status.completed()) { + return status; + } + Thread.sleep(1_000); + } + throw new IllegalStateException("Gitea workflow run did not complete"); + } + + private record RunnerContainer(String id) implements AutoCloseable { + + @Override + public void close() throws Exception { + run(docker(), "rm", "-f", id); + } + } + + private record GiteaContainer(String id, String webUrl) implements AutoCloseable { + + static GiteaContainer start() throws Exception { + final String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 12); + final String network = "gwplugin-" + suffix; + final String name = "gwplugin-gitea-" + suffix; + run(docker(), "network", "create", network); + final String id = run(docker(), "run", "--rm", "-d", + "--name", name, + "--network", network, + "-p", "127.0.0.1::3000", + "-e", "GITEA__database__DB_TYPE=sqlite3", + "-e", "GITEA__database__PATH=/var/lib/gitea/gitea.db", + "-e", "GITEA__security__INSTALL_LOCK=true", + "-e", "GITEA__service__DISABLE_REGISTRATION=true", + "-e", "GITEA__server__HTTP_PORT=3000", + "-e", "GITEA__server__ROOT_URL=http://" + name + ":3000/", + "-e", "GITEA__actions__ENABLED=true", + IMAGE + ).trim(); + final String port = run(docker(), "port", id, "3000/tcp").trim().replaceFirst(".*:", ""); + final GiteaContainer container = new GiteaContainer(id, "http://127.0.0.1:" + port); + container.waitUntilReady(); + return container; + } + + String apiUrl() { + return webUrl + "/api/v1"; + } + + RunnerContainer startRunner() throws Exception { + final String runnerName = "gwplugin-runner-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + final String runnerToken = run(docker(), "exec", id, "gitea", "actions", "generate-runner-token") + .lines() + .reduce((first, second) -> second) + .orElse("") + .trim(); + if (runnerToken.isBlank()) { + throw new IllegalStateException("Gitea runner token was not printed"); + } + final String network = run(docker(), "inspect", "-f", "{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{end}}", id).trim(); + run(docker(), "run", "-d", + "--name", runnerName, + "--network", network, + "-e", "GITEA_INSTANCE_URL=http://" + containerName() + ":3000", + "-e", "GITEA_RUNNER_REGISTRATION_TOKEN=" + runnerToken, + "-e", "GITEA_RUNNER_NAME=plugin-smoke", + "-e", "GITEA_RUNNER_EPHEMERAL=1", + "-e", "GITEA_RUNNER_LABELS=ubuntu-latest:host", + RUNNER_IMAGE + ); + waitUntilRunnerReady(runnerName); + return new RunnerContainer(runnerName); + } + + private String containerName() throws Exception { + return run(docker(), "inspect", "-f", "{{.Name}}", id).trim().replaceFirst("^/", ""); + } + + String createAdminToken() throws Exception { + run(docker(), "exec", id, "gitea", "admin", "user", "create", + "--username", "test", + "--password", "test-password", + "--email", "test@example.com", + "--admin", + "--must-change-password=false" + ); + final String output = run(docker(), "exec", id, "gitea", "admin", "user", "generate-access-token", + "--username", "test", + "--token-name", "plugin-test", + "--scopes", "all" + ); + return Pattern.compile("created:\\s*(\\S+)") + .matcher(output) + .results() + .map(result -> result.group(1)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Gitea token was not printed")); + } + + void createRepository(final String token, final String name) throws Exception { + final JsonObject body = new JsonObject(); + body.addProperty("name", name); + body.addProperty("auto_init", true); + post(token, "/user/repos", body); + } + + void createFile(final String token, final String repo, final String path, final String content) throws Exception { + final JsonObject body = new JsonObject(); + body.addProperty("branch", "main"); + body.addProperty("message", "add " + path); + body.addProperty("content", Base64.getEncoder().encodeToString(content.getBytes(StandardCharsets.UTF_8))); + post(token, "/repos/test/" + encode(repo) + "/contents/" + path, body); + } + + private void post(final String token, final String path, final JsonObject body) throws Exception { + final HttpRequest request = HttpRequest.newBuilder(URI.create(apiUrl() + path)) + .timeout(Duration.ofSeconds(10)) + .header("Authorization", "token " + token) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)) + .build(); + final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) { + throw new IllegalStateException("Gitea API failed with HTTP " + response.statusCode() + ": " + response.body()); + } + } + + private void waitUntilReady() throws Exception { + for (int attempt = 0; attempt < 30; attempt++) { + try { + final HttpRequest request = HttpRequest.newBuilder(URI.create(apiUrl() + "/version")) + .timeout(Duration.ofSeconds(2)) + .GET() + .build(); + if (CLIENT.send(request, HttpResponse.BodyHandlers.discarding()).statusCode() == 200) { + return; + } + } catch (final IOException ignored) { + // Gitea is still waking up. + } + Thread.sleep(1_000); + } + throw new IllegalStateException("Gitea did not become ready"); + } + + private static void waitUntilRunnerReady(final String runnerName) throws Exception { + final Pattern ready = Pattern.compile("(?i)(runner registered successfully|declare successfully)"); + for (int attempt = 0; attempt < 45; attempt++) { + final String logs = runCombined(docker(), "logs", runnerName); + if (ready.matcher(logs).find()) { + return; + } + Thread.sleep(1_000); + } + throw new IllegalStateException("Gitea runner did not become ready: " + runCombined(docker(), "logs", runnerName)); + } + + @Override + public void close() throws Exception { + final String network = run(docker(), "inspect", "-f", "{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{end}}", id).trim(); + try { + run(docker(), "stop", id); + } finally { + if (!network.isBlank()) { + run(docker(), "network", "rm", network); + } + } + } + } + + private static String encode(final String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static String docker() throws IOException, InterruptedException { + final List candidates = new ArrayList<>(); + DOCKER_ENV_NAMES.stream() + .map(System::getenv) + .filter(value -> value != null && !value.isBlank()) + .forEach(candidates::add); + probe("sh", "-lc", "command -v docker").ifPresent(candidates::add); + probe("sh", "-lc", "which docker").ifPresent(candidates::add); + probe("cmd", "/c", "where docker").ifPresent(candidates::add); + commonDockerPaths().forEach(candidates::add); + + return candidates.stream() + .map(String::trim) + .filter(value -> !value.isBlank()) + .flatMap(value -> value.lines().map(String::trim)) + .filter(value -> !value.isBlank()) + .distinct() + .filter(GiteaDockerIntegrationTest::canRunDocker) + .findFirst() + .orElseThrow(() -> new IOException(""" + Docker CLI was not found for the Gitea integration test. + Set GITEA_DOCKER_BIN, DOCKER_BIN, or DOCKER_CLI to the docker executable, + or make `docker` visible to command -v / which / where. + """.strip())); + } + + private static List commonDockerPaths() { + final String home = System.getProperty("user.home", ""); + return List.of( + "/opt/homebrew/opt/docker/bin/docker", + "/opt/homebrew/bin/docker", + "/usr/local/bin/docker", + "/Applications/Docker.app/Contents/Resources/bin/docker", + "/Applications/OrbStack.app/Contents/MacOS/docker", + "/Applications/Rancher Desktop.app/Contents/Resources/resources/darwin/bin/docker", + home + "/.docker/bin/docker", + home + "/.rd/bin/docker", + home + "/.orbstack/bin/docker", + home + "/Applications/Docker.app/Contents/Resources/bin/docker", + home + "/Applications/OrbStack.app/Contents/MacOS/docker" + ); + } + + private static Optional probe(final String... command) { + try { + final Process process = new ProcessBuilder(command) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + final String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + if (process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS) && process.exitValue() == 0 && !output.isBlank()) { + return output.lines().map(String::trim).filter(value -> !value.isBlank()).findFirst(); + } + process.destroyForcibly(); + } catch (final IOException ignored) { + // Candidate discovery is best effort. + } catch (final InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + return Optional.empty(); + } + + private static boolean canRunDocker(final String command) { + if (!isExecutable(command)) { + return false; + } + try { + final Process process = new ProcessBuilder(command, "version") + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + if (process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)) { + return process.exitValue() == 0; + } + process.destroyForcibly(); + } catch (final IOException ignored) { + // Candidate validation is best effort. + } catch (final InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + return false; + } + + private static boolean isExecutable(final String command) { + try { + return Files.isExecutable(Path.of(command)); + } catch (final InvalidPathException ignored) { + return false; + } + } + + private static String run(final String... command) throws IOException, InterruptedException { + final Path errorLog = Files.createTempFile("gitea-docker-", ".err"); + try { + final Process process = new ProcessBuilder(command) + .redirectError(errorLog.toFile()) + .start(); + final String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(180, java.util.concurrent.TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("Command timed out [" + String.join(" ", command) + "]: " + commandOutput(output, errorLog)); + } + if (process.exitValue() != 0) { + throw new IOException("Command failed [" + String.join(" ", command) + "]: " + commandOutput(output, errorLog)); + } + return output; + } finally { + Files.deleteIfExists(errorLog); + } + } + + private static String runCombined(final String... command) throws IOException, InterruptedException { + final Path errorLog = Files.createTempFile("gitea-docker-", ".err"); + try { + final Process process = new ProcessBuilder(command) + .redirectError(errorLog.toFile()) + .start(); + final String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(180, java.util.concurrent.TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("Command timed out [" + String.join(" ", command) + "]: " + commandOutput(output, errorLog)); + } + if (process.exitValue() != 0) { + throw new IOException("Command failed [" + String.join(" ", command) + "]: " + commandOutput(output, errorLog)); + } + return commandOutput(output, errorLog); + } finally { + Files.deleteIfExists(errorLog); + } + } + + private static String commandOutput(final String output, final Path errorLog) throws IOException { + final String error = Files.readString(errorLog, StandardCharsets.UTF_8); + return (output + System.lineSeparator() + error).trim(); + } +} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/git/RemoteActionProvidersTest.java b/src/test/java/com/github/yunabraska/githubworkflow/git/RemoteActionProvidersTest.java index 55a8f3e..353fb19 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/git/RemoteActionProvidersTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/git/RemoteActionProvidersTest.java @@ -17,7 +17,9 @@ public class RemoteActionProvidersTest extends BasePlatformTestCase { @Override protected void tearDown() throws Exception { try { - RemoteActionProviders.Settings.getInstance().setCustomServers(List.of()); + final RemoteActionProviders.Settings settings = RemoteActionProviders.Settings.getInstance(); + settings.customServers().forEach(settings::clearGiteaToken); + settings.setCustomServers(List.of()); } finally { super.tearDown(); } @@ -175,6 +177,34 @@ public void testGithubReusableWorkflowPathIsDetectedAsWorkflowMetadata() throws } } + public void testGiteaReusableWorkflowPathIsDetectedAsWorkflowMetadata() throws Exception { + try (FakeRemoteServer server = new FakeRemoteServer()) { + server.addContent("acme", "automation", ".gitea/workflows/reuse.yml", "main", """ + name: Reuse + on: + workflow_call: + inputs: + config: + type: string + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + useGiteaServer(server, ""); + + final String usesValue = server.webUrl() + "/acme/automation/.gitea/workflows/reuse.yml@main"; + final GitHubAction action = GitHubAction.createGithubAction(false, usesValue, server.webUrl()).resolve(); + + assertThat(action.isResolved()).isTrue(); + assertThat(action.isAction()).isFalse(); + assertThat(action.freshInputs()).containsKey("config"); + assertThat(action.githubUrl()).isEqualTo(server.webUrl() + "/acme/automation/blob/main/.gitea/workflows/reuse.yml"); + assertThat(server.requests()).contains("/api/v1/repos/acme/automation/contents/.gitea/workflows/reuse.yml?ref=main"); + } + } + public void testLatestRefsReturnsLatestTenTagsBeforeBranches() throws Exception { try (FakeRemoteServer server = new FakeRemoteServer()) { server.setTags("acme", "tool", List.of("v10", "v9", "v8", "v7", "v6", "v5", "v4", "v3", "v2", "v1", "v0")); @@ -232,6 +262,193 @@ public void testStandardEnvironmentTokensAreTriedBeforeAnonymous() { .contains("Bearer env-token"); } + public void testGiteaEnvironmentTokensUseTokenAuthorizationScheme() { + final RemoteActionProviders.Server server = RemoteActionProviders.Server.gitea( + "Local Gitea", + "http://gitea.local", + "http://gitea.local/api/v1", + "LOCAL_GITEA_TOKEN", + true + ); + + final List authorizations = RemoteActionProviders.Authorizations.forServer( + server, + null, + Map.of("LOCAL_GITEA_TOKEN", "local-token", "GITEA_TOKEN", "default-token", "GITHUB_TOKEN", "wrong-for-gitea") + ); + + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsExactly("LOCAL_GITEA_TOKEN", "GITEA_TOKEN", "anonymous"); + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::authorizationHeader) + .containsExactly("token local-token", "token default-token", ""); + } + + public void testGiteaWorkflowRunEnvironmentTokensUseTokenAuthorizationScheme() { + final List authorizations = RemoteActionProviders.Authorizations.forWorkflowRun( + "http://gitea.local/api/v1", + ".gitea/workflows/build.yml", + "LOCAL_GITEA_TOKEN", + null, + Map.of("LOCAL_GITEA_TOKEN", "local-token", "GITEA_TOKEN", "default-token", "GITHUB_TOKEN", "wrong-for-gitea") + ); + + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsExactly("LOCAL_GITEA_TOKEN", "GITEA_TOKEN", "anonymous"); + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::authorizationHeader) + .containsExactly("token local-token", "token default-token", ""); + } + + public void testGiteaStoredTokenIsTriedBeforeEnvironmentTokens() { + final RemoteActionProviders.Server server = RemoteActionProviders.Server.gitea( + "Local Gitea", + "http://gitea.local", + "http://gitea.local/api/v1", + "LOCAL_GITEA_TOKEN", + true + ); + final RemoteActionProviders.Settings settings = RemoteActionProviders.Settings.getInstance() + .setCustomServers(List.of(server)) + .setGiteaToken(server, "stored-token"); + + final List authorizations = RemoteActionProviders.Authorizations.forServer( + server, + null, + Map.of("LOCAL_GITEA_TOKEN", "local-token", "GITEA_TOKEN", "default-token") + ); + + assertThat(settings.hasGiteaToken(server)).isTrue(); + assertThat(settings.customServers().getFirst().tokenStored).isTrue(); + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsExactly("Local Gitea", "LOCAL_GITEA_TOKEN", "GITEA_TOKEN", "anonymous"); + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::authorizationHeader) + .containsExactly("token stored-token", "token local-token", "token default-token", ""); + } + + public void testGiteaStoredTokenForMatchingServerBeatsOtherGiteaTokens() { + final RemoteActionProviders.Server first = RemoteActionProviders.Server.gitea( + "First Gitea", + "http://first.local", + "http://first.local/api/v1", + "", + true + ); + final RemoteActionProviders.Server second = RemoteActionProviders.Server.gitea( + "Second Gitea", + "http://second.local", + "http://second.local/api/v1", + "", + true + ); + RemoteActionProviders.Settings.getInstance() + .setCustomServers(List.of(first, second)) + .setGiteaToken(first, "first-token") + .setGiteaToken(second, "second-token"); + + final List authorizations = RemoteActionProviders.Authorizations.forWorkflowRun( + "http://second.local/api/v1", + ".gitea/workflows/build.yml", + "", + null, + Map.of() + ); + + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsExactly("Second Gitea", "First Gitea", "anonymous"); + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::authorizationHeader) + .containsExactly("token second-token", "token first-token", ""); + } + + public void testGiteaStoredTokenIsNotSerializedInSettingsState() { + final RemoteActionProviders.Server server = RemoteActionProviders.Server.gitea( + "Local Gitea", + "http://gitea.local", + "http://gitea.local/api/v1", + "LOCAL_GITEA_TOKEN", + true + ); + + final RemoteActionProviders.Settings settings = RemoteActionProviders.Settings.getInstance() + .setCustomServers(List.of(server)) + .setGiteaToken(server, "stored-token"); + + assertThat(settings.getState().servers) + .hasSize(1) + .allSatisfy(state -> assertThat(List.of( + state.type, + state.name, + state.webUrl, + state.apiUrl, + state.tokenEnvVar + )).doesNotContain("stored-token")); + assertThat(settings.getState().servers.getFirst().tokenEnvVar).isEqualTo("LOCAL_GITEA_TOKEN"); + assertThat(settings.getState().servers.getFirst().tokenStored).isTrue(); + } + + public void testGiteaStoredTokenMarkerIsClearedWithToken() { + final RemoteActionProviders.Server server = RemoteActionProviders.Server.gitea( + "Local Gitea", + "http://gitea.local", + "http://gitea.local/api/v1", + "LOCAL_GITEA_TOKEN", + true + ); + final RemoteActionProviders.Settings settings = RemoteActionProviders.Settings.getInstance() + .setCustomServers(List.of(server)) + .setGiteaToken(server, "stored-token"); + + settings.clearGiteaToken(server); + + assertThat(settings.hasGiteaToken(server)).isFalse(); + assertThat(settings.customServers().getFirst().tokenStored).isFalse(); + } + + public void testDisabledGiteaServerIsStoredButNotUsed() { + final RemoteActionProviders.Server server = RemoteActionProviders.Server.gitea( + "Quiet Gitea", + "http://gitea.local", + "http://gitea.local/api/v1", + "LOCAL_GITEA_TOKEN", + false + ); + final RemoteActionProviders.Settings settings = RemoteActionProviders.Settings.getInstance() + .setCustomServers(List.of(server)); + + assertThat(settings.customServers()).containsExactly(server.normalized()); + assertThat(settings.enabledServers()) + .noneMatch(candidate -> "http://gitea.local/api/v1".equals(candidate.apiUrl)); + } + + public void testGithubEnvironmentTokensStillUseBearerAuthorizationScheme() { + final RemoteActionProviders.Server server = new RemoteActionProviders.Server( + "GitHub", + "https://github.com", + "https://api.github.com", + "LOCAL_GITHUB_TOKEN", + true + ); + + final List authorizations = RemoteActionProviders.Authorizations.forServer( + server, + null, + Map.of("LOCAL_GITHUB_TOKEN", "local-token", "GITHUB_TOKEN", "default-token", "GITEA_TOKEN", "wrong-for-github") + ); + + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsSubsequence("LOCAL_GITHUB_TOKEN", "GITHUB_TOKEN", "anonymous"); + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::authorizationHeader) + .contains("Bearer local-token", "Bearer default-token"); + } + public void testExplicitEnvironmentTokenIsTriedBeforeStandardEnvironmentTokens() { final List authorizations = RemoteActionProviders.Authorizations.forApiUrl( "https://github.acme.test/api/v3", @@ -267,4 +484,14 @@ private static void useServer(final FakeRemoteServer server, final String apiPre true ))); } + + private static void useGiteaServer(final FakeRemoteServer server, final String tokenEnvVar) { + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(RemoteActionProviders.Server.gitea( + "Fake Gitea", + server.webUrl(), + server.apiUrl("/api/v1"), + tokenEnvVar, + true + ))); + } } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java b/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java index 3f8f2f1..8f21111 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java @@ -109,6 +109,7 @@ public void testLocaleBundleValuesAreTranslatedAndKeepPlaceholders() throws IOEx "error.report.ide", "error.report.os", "error.report.stacktrace", + "settings.gitea.displayName", "completion.shell.powershell" ); final Pattern placeholder = Pattern.compile("\\{\\d+\\}"); @@ -144,7 +145,8 @@ public void testLocalizedBundlesDoNotKeepKnownEnglishMergeLeftovers() throws IOE "action.GitHubWorkflow.RefreshActionCache.text", "action.GitHubWorkflow.RefreshActionCache.description", "settings.cache.refresh", - "workflow.run.auth.settings" + "workflow.run.auth.settings.github", + "workflow.run.auth.settings.gitea" ); for (final String suffix : LOCALE_SUFFIXES) { final Properties bundle = loadBundle("_" + suffix); @@ -185,6 +187,7 @@ public void testEveryConfiguredLocaleResolvesSettingsAndInspectionMessages() { for (final String suffix : LOCALE_SUFFIXES) { final Locale locale = Locale.forLanguageTag(suffix.replace('_', '-')); assertThat(GitHubWorkflowBundle.messageFor(locale, "settings.displayName")).isNotBlank(); + assertThat(GitHubWorkflowBundle.messageFor(locale, "settings.gitea.displayName")).isEqualTo("Gitea"); assertThat(GitHubWorkflowBundle.messageFor(locale, "inspection.action.delete.invalid", "input", "bad")) .contains("bad"); assertThat(GitHubWorkflowBundle.messageFor(locale, "inspection.output.unused", "artifact")) @@ -289,6 +292,7 @@ public void testWorkflowSyntaxCompletionDescriptionsResolveForEveryLocale() { "completion.workflow.permission.shorthand.empty", "completion.workflow.job.runs-on", "completion.workflow.job.steps", + "completion.workflow.job.gitea.ignored", "completion.workflow.step.uses", "completion.workflow.step.run", "completion.workflow.defaultsRun.shell", @@ -304,7 +308,12 @@ public void testWorkflowSyntaxCompletionDescriptionsResolveForEveryLocale() { "completion.workflow.credentials.password", "completion.workflow.inputType.choice", "completion.workflow.boolean.true", - "completion.workflow.runner.ubuntu-latest" + "completion.workflow.runner.ubuntu-latest", + "completion.secret.giteaToken", + "completion.env.gitea", + "completion.expressionFunction", + "inspection.workflow.syntax.giteaRunsOnSingleLabel", + "inspection.workflow.syntax.giteaExpressionFunction" ); for (final String suffix : LOCALE_SUFFIXES) { final Locale locale = Locale.forLanguageTag(suffix.replace('_', '-')); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandlerTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandlerTest.java index 5529a5e..50c818b 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandlerTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandlerTest.java @@ -415,6 +415,51 @@ public void processTerminated(@NotNull final ProcessEvent event) { .contains("feature/live-logs"); } + public void testProcessUsesGiteaWorkflowUrlInDispatchMessage() throws Exception { + final AtomicInteger statusCalls = new AtomicInteger(0); + final AtomicInteger jobCalls = new AtomicInteger(0); + final AtomicInteger logCalls = new AtomicInteger(0); + final WorkflowRun client = new WorkflowRun( + request -> responseFor(request, statusCalls, jobCalls, logCalls), + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) + ); + final WorkflowRun.Request request = new WorkflowRun.Request( + "http://localhost:3000/api/v1", + "basuabhi", + "demo-app", + ".gitea/workflows/build-update-chart.yaml", + "main", + Map.of(), + "GITEA_TOKEN" + ); + final WorkflowRunProcessHandler handler = new WorkflowRunProcessHandler( + getProject(), + request, + client, + new WorkflowRunProcessHandler.PollSettings(10, 10, 10) + ); + final CountDownLatch terminated = new CountDownLatch(1); + final StringBuilder output = new StringBuilder(); + handler.addProcessListener(new ProcessListener() { + @Override + public void onTextAvailable(@NotNull final ProcessEvent event, @NotNull final com.intellij.openapi.util.Key outputType) { + output.append(event.getText()); + } + + @Override + public void processTerminated(@NotNull final ProcessEvent event) { + terminated.countDown(); + } + }); + + handler.startNotify(); + + assertThat(terminated.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(output.toString()) + .contains("http://localhost:3000/basuabhi/demo-app/blob/main/.gitea/workflows/build-update-chart.yaml") + .doesNotContain("http://localhost:3000/api/v1/basuabhi/demo-app/blob"); + } + public void testProcessRetriesLiveLogAfterDeferredHttpFailure() throws Exception { final AtomicInteger statusCalls = new AtomicInteger(0); final AtomicInteger logCalls = new AtomicInteger(0); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunTest.java index 8173d95..94ebeeb 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunTest.java @@ -16,6 +16,7 @@ import java.net.http.HttpResponse; import java.net.http.HttpClient.Version; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -102,10 +103,99 @@ public void testLatestRunDiscoversNewestWorkflowDispatchRun() throws Exception { assertThat(result.get().runId()).isEqualTo(77); assertThat(result.get().status()).isEqualTo("queued"); assertThat(result.get().htmlUrl()).isEqualTo("html-latest"); - assertThat(server.requests()).contains("/repos/acme/tool/actions/workflows/build.yml/runs?branch=feature%2Fone&event=workflow_dispatch&per_page=1"); + assertThat(server.requests()).contains("/repos/acme/tool/actions/workflows/build.yml/runs?branch=feature%2Fone&event=workflow_dispatch&per_page=20"); } } + public void testGiteaDispatchRequestsRunDetailsAndLatestRunUsesRepositoryRunsEndpoint() throws Exception { + try (FakeWorkflowRunServer server = new FakeWorkflowRunServer()) { + final HttpClient httpClient = HttpClient.newHttpClient(); + final WorkflowRun client = new WorkflowRun( + request -> httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)), + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) + ); + final WorkflowRun.Request request = new WorkflowRun.Request( + server.apiUrl() + "/api/v1", + "acme", + "tool", + ".gitea/workflows/build-update-chart.yaml", + "main", + Map.of(), + "GITEA_TOKEN" + ); + + final WorkflowRun.DispatchResult dispatch = client.dispatch(request); + final var latest = client.latestRun(request); + + assertThat(dispatch.runId()).isEqualTo(42); + assertThat(latest).isPresent(); + assertThat(latest.get().runId()).isEqualTo(88); + assertThat(server.requests()).contains( + "/api/v1/repos/acme/tool/actions/workflows/build-update-chart.yaml/dispatches?return_run_details=true", + "/api/v1/repos/acme/tool/actions/runs?branch=main&event=workflow_dispatch&limit=20" + ); + assertThat(server.requests()).noneMatch(path -> path.contains("/workflows/build-update-chart.yaml/runs")); + } + } + + public void testLatestRunPrefersSameGiteaWorkflowClosestToDispatchTime() throws Exception { + final List requests = new ArrayList<>(); + final WorkflowRun client = new WorkflowRun( + request -> { + requests.add(request.uri()); + return new ClientResponse(request, 200, "application/json", """ + {"workflow_runs":[ + {"id":99,"status":"queued","conclusion":null,"html_url":"wrong-newer","path":".gitea/workflows/other.yaml","created_at":"2026-06-15T10:00:03Z"}, + {"id":88,"status":"queued","conclusion":null,"html_url":"right","path":".gitea/workflows/build-update-chart.yaml","created_at":"2026-06-15T10:00:02Z"}, + {"id":77,"status":"queued","conclusion":null,"html_url":"right-old","path":".gitea/workflows/build-update-chart.yaml","created_at":"2026-06-15T09:00:00Z"} + ]} + """); + }, + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) + ); + final WorkflowRun.Request request = new WorkflowRun.Request( + "https://gitea.local/api/v1", + "acme", + "tool", + ".gitea/workflows/build-update-chart.yaml", + "main", + Map.of(), + "GITEA_TOKEN" + ); + + final Optional result = client.latestRun(request, Instant.parse("2026-06-15T10:00:00Z")); + + assertThat(result).isPresent(); + assertThat(result.get().runId()).isEqualTo(88); + assertThat(result.get().htmlUrl()).isEqualTo("right"); + assertThat(requests.get(0).getRawPath()).isEqualTo("/api/v1/repos/acme/tool/actions/runs"); + assertThat(requests.get(0).getRawQuery()).isEqualTo("branch=main&event=workflow_dispatch&limit=20"); + } + + public void testLatestRunReturnsEmptyWhenGiteaWorkflowPathDoesNotMatch() throws Exception { + final WorkflowRun client = new WorkflowRun( + request -> new ClientResponse(request, 200, "application/json", """ + {"workflow_runs":[ + {"id":99,"status":"queued","conclusion":null,"html_url":"wrong","path":".gitea/workflows/other.yaml","created_at":"2026-06-15T10:00:03Z"} + ]} + """), + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) + ); + final WorkflowRun.Request request = new WorkflowRun.Request( + "https://gitea.local/api/v1", + "acme", + "tool", + ".gitea/workflows/build-update-chart.yaml", + "main", + Map.of(), + "GITEA_TOKEN" + ); + + final Optional result = client.latestRun(request, Instant.parse("2026-06-15T10:00:00Z")); + + assertThat(result).isEmpty(); + } + public void testArtifactsAndZipUseRunArtifactEndpoints() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer()) { final WorkflowRun client = new WorkflowRun(); @@ -252,10 +342,37 @@ public void testDispatchAuthenticationFailureMentionsGithubSettings() throws Exc ); final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); - assertThatExceptionOfType(WorkflowRun.WorkflowRunHttpException.class) - .isThrownBy(() -> client.dispatch(request)) - .withMessageContaining("GitHub workflow dispatch failed with HTTP 401") - .withMessageContaining("Settings > Version Control > GitHub"); + try { + client.dispatch(request); + fail("Expected workflow dispatch to require authentication"); + } catch (final WorkflowRun.WorkflowRunHttpException exception) { + assertThat(exception.getMessage()) + .contains("GitHub workflow dispatch failed with HTTP 401") + .contains("Settings > Version Control > GitHub"); + assertThat(exception.settingsId()).isEqualTo("GitHub"); + } + } + } + + public void testGiteaDispatchAuthenticationFailureMentionsGiteaSettings() throws Exception { + try (FakeWorkflowRunServer server = new FakeWorkflowRunServer(false, 1)) { + final HttpClient httpClient = HttpClient.newHttpClient(); + final WorkflowRun client = new WorkflowRun( + request -> httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)), + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) + ); + final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl().replace("/api", "/api/v1"), "acme", "tool", ".gitea/workflows/build.yml", "main", Map.of(), ""); + + try { + client.dispatch(request); + fail("Expected Gitea workflow dispatch to require authentication"); + } catch (final WorkflowRun.WorkflowRunHttpException exception) { + assertThat(exception.getMessage()) + .contains("GitHub workflow dispatch failed with HTTP 401") + .contains("Settings > Version Control > Gitea") + .doesNotContain("Settings > Version Control > GitHub"); + assertThat(exception.settingsId()).isEqualTo("github.workflow.gitea.settings"); + } } } @@ -369,6 +486,9 @@ private Response responseFor(final String path) { if (path.endsWith("/workflows/build.yml/runs")) { return new Response(200, "application/json", "{\"workflow_runs\":[{\"id\":77,\"status\":\"queued\",\"conclusion\":null,\"html_url\":\"html-latest\"}]}"); } + if (path.endsWith("/actions/runs")) { + return new Response(200, "application/json", "{\"workflow_runs\":[{\"id\":88,\"status\":\"queued\",\"conclusion\":null,\"html_url\":\"gitea-latest\"}]}"); + } if (path.endsWith("/runs/42/jobs")) { return new Response(200, "application/json", "{\"jobs\":[{\"id\":100,\"name\":\"build\",\"status\":\"completed\",\"conclusion\":\"success\"}]}"); } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowMetadataTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowMetadataTest.java index aaf45b1..61f9f3a 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowMetadataTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowMetadataTest.java @@ -5,8 +5,9 @@ import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; -import java.nio.file.Path; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Objects; @@ -146,6 +147,17 @@ public void runnerDebugDescriptionMatchesDocumentedMeaning() { .doesNotContain("preinstalled tools"); } + @Test + public void shellDescriptionsAreLoadedLazily() throws Exception { + final String source = Files.readString(Path.of( + "src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java" + )); + + assertThat(source).doesNotContain("static final Map SHELLS = initShells()"); + assertThat(WorkflowContextCatalog.shells()) + .containsKeys("bash", "sh", "pwsh", "powershell", "cmd", "python"); + } + private static List resourceKeys(final String path) throws Exception { try (InputStream stream = Objects.requireNonNull(WorkflowMetadataTest.class.getResourceAsStream(path)); BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { @@ -162,6 +174,10 @@ 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.isGithubWorkflowFile(Path.of("repo", ".github", "workflows", "build.yml"))).isTrue(); + assertThat(WorkflowYaml.isGithubWorkflowFile(Path.of("repo", ".gitea", "workflows", "build.yml"))).isFalse(); + assertThat(WorkflowYaml.isGiteaWorkflowFile(Path.of("repo", ".gitea", "workflows", "build.yml"))).isTrue(); + assertThat(WorkflowYaml.isGiteaWorkflowFile(Path.of("repo", ".github", "workflows", "build.yml"))).isFalse(); assertThat(WorkflowYaml.isWorkflowFile(Path.of("repo", ".github", "not-workflows", "build.yml"))).isFalse(); assertThat(WorkflowYaml.isWorkflowFile(Path.of("repo", "workflows", "build.yml"))).isFalse(); } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java index 7e29745..8f7023b 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java @@ -392,6 +392,18 @@ public void testEnvCompletionIncludesWorkflowJobStepAndRunEnvironmentValues() { """)).contains("WORKFLOW_LEVEL", "JOB_LEVEL", "STEP_LEVEL", "RUN_LEVEL"); } + public void testGiteaRunEnvironmentCompletionIncludesGiteaRunnerVariables() { + assertThat(completeGiteaWorkflow(""" + name: Completion + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "$GITEA" + """)).contains("GITEA_OUTPUT", "GITEA_ENV", "GITEA_STEP_SUMMARY"); + } + public void testEnvCompletionIncludesJobEnvMapAliasValues() { assertThat(completeWorkflow(""" name: Completion @@ -744,6 +756,19 @@ public void testWorkflowCallSecretDefinitionCompletionSuggestsSecretProperties() """)).contains("description", "required"); } + public void testGiteaSecretCompletionSuggestsGiteaToken() { + assertThat(completeGiteaWorkflow(""" + name: Completion + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ secrets. }}" + """)).contains("GITEA_TOKEN") + .doesNotContain("GITHUB_TOKEN"); + } + public void testWorkflowDispatchInputTypeCompletionSuggestsDispatchTypes() { assertThat(completeWorkflow(""" name: Completion @@ -806,6 +831,37 @@ public void testPermissionScopeCompletionShowsDescriptions() { .containsEntry("models", "GitHub Models"); } + public void testGiteaPermissionScopeCompletionUsesGiteaTokenScopes() { + assertThat(completeGiteaWorkflow(""" + name: Completion + on: workflow_dispatch + permissions: + + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """)).contains("actions", "contents", "code", "releases", "wiki", "projects", "packages") + .doesNotContain("id-token", "statuses", "checks", "deployments", "pages", "security-events"); + } + + public void testGiteaPermissionScopeCompletionKeepsDescriptionsTranslated() { + assertThat(completeGiteaWorkflowTypeTexts(""" + name: Completion + on: workflow_dispatch + permissions: + + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """)).containsEntry("contents", "Repository contents") + .containsEntry("wiki", "Wiki page changed") + .containsEntry("projects", "Classic project changed"); + } + public void testPermissionValueCompletionSuggestsReadWriteNone() { assertThat(completeWorkflow(""" name: Completion @@ -833,6 +889,47 @@ public void testPermissionShorthandCompletionSuggestsReadAllWriteAllAndEmpty() { """)).contains("read-all", "write-all", "{}"); } + public void testGiteaPermissionShorthandCompletionUsesDocumentedValues() { + assertThat(completeGiteaWorkflow(""" + name: Completion + on: workflow_dispatch + permissions: + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """)).contains("read-all", "write-all") + .doesNotContain("{}"); + } + + public void testGiteaScheduleCompletionSuggestsCronAliases() { + assertThat(completeGiteaWorkflow(""" + name: Completion + on: + schedule: + - cron: + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """)).contains("@yearly", "@monthly", "@weekly", "@daily", "@hourly"); + } + + public void testGiteaRootExpressionCompletionKeepsDocumentedFunctionSetSmall() { + assertThat(completeGiteaWorkflow(""" + name: Completion + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ }}" + """)).contains("always()") + .doesNotContain("success()", "failure()", "cancelled()", "hashFiles()", "startsWith()"); + } + public void testIdTokenPermissionCompletionSuggestsOnlyWriteOrNone() { assertThat(completeWorkflow(""" name: Completion @@ -871,6 +968,18 @@ public void testJobSyntaxCompletionSuggestsDocumentedJobKeys() { """)).contains("runs-on", "permissions", "environment", "strategy", "container", "services", "uses"); } + public void testGiteaIgnoredJobKeysExplainRuntimeNoop() { + assertThat(completeGiteaWorkflowTypeTexts(""" + name: Completion + on: workflow_dispatch + jobs: + build: + + """)).containsEntry("timeout-minutes", "Accepted by Gitea, ignored at runtime.") + .containsEntry("continue-on-error", "Accepted by Gitea, ignored at runtime.") + .containsEntry("environment", "Accepted by Gitea, ignored at runtime."); + } + public void testWorkflowSyntaxCompletionDescriptionsUseConfiguredLanguageAfterEnglishTableUse() { assertThat(completeWorkflowTypeTexts(""" name: Completion @@ -2012,6 +2121,18 @@ private Map completeWorkflowTypeTexts(final String text) { )); } + private Map completeGiteaWorkflowTypeTexts(final String text) { + configureGiteaWorkflowProjectFile(text); + final LookupElement[] elements = myFixture.completeBasic(); + assertThat(elements).isNotNull(); + return java.util.Arrays.stream(elements) + .collect(Collectors.toMap( + LookupElement::getLookupString, + WorkflowSyntaxCompletionTest::typeText, + (left, right) -> left + )); + } + private void writeProjectFile(final String relativePath, final String content) throws Exception { final Path path = Path.of(getProject().getBasePath(), relativePath); Files.createDirectories(path.getParent()); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowValidationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowValidationTest.java index 5c34d30..0ee29ad 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowValidationTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowValidationTest.java @@ -311,6 +311,88 @@ public void testRestrictedPermissionValueIsHighlighted() { """); } + public void testGiteaSpecificPermissionScopesAreAccepted() { + assertGiteaWorkflowHighlights(""" + name: Syntax + on: workflow_dispatch + permissions: + code: read + releases: write + wiki: none + projects: read + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + } + + public void testGiteaRejectsGithubOnlyPermissionScopes() { + assertGiteaWorkflowHighlights(""" + name: Syntax + on: workflow_dispatch + permissions: + id-token: write + statuses: read + pages: write + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + } + + public void testGiteaWarnsAboutMultipleRunsOnLabels() { + assertGiteaWorkflowHighlights(""" + name: Syntax + on: workflow_dispatch + jobs: + build: + runs-on: [self-hosted, linux] + steps: + - run: echo ok + """); + } + + public void testGiteaAcceptsSingleRunsOnLabelArray() { + assertGiteaWorkflowHighlights(""" + name: Syntax + on: workflow_dispatch + jobs: + build: + runs-on: [ubuntu-latest] + steps: + - run: echo ok + """); + } + + public void testGiteaWarnsAboutUnsupportedExpressionFunctions() { + assertGiteaWorkflowHighlights(""" + name: Syntax + on: workflow_dispatch + jobs: + build: + if: always() && startsWith(gitea.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - run: echo "${{ hashFiles('package.json') }}" + """); + } + + public void testGiteaIgnoresExpressionFunctionNamesInsideStrings() { + assertGiteaWorkflowHighlights(""" + name: Syntax + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ always() && 'startsWith(' != '' }}" + """); + } + public void testResolvedActionInputIsAccepted() { seedRemoteAction("owner/tool@v1", Map.of("known-input", "Known input"), Map.of()); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/test/EditorFeatureTestCase.java b/src/test/java/com/github/yunabraska/githubworkflow/test/EditorFeatureTestCase.java index d243a65..f98fd80 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/test/EditorFeatureTestCase.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/test/EditorFeatureTestCase.java @@ -78,15 +78,33 @@ protected final void assertWorkflowHighlights(final String text) { myFixture.checkHighlighting(true, false, true); } + protected final void assertGiteaWorkflowHighlights(final String text) { + configureGiteaWorkflowProjectFile(text); + myFixture.checkHighlighting(true, false, true); + } + protected final void configureWorkflow(final String text) { myFixture.configureByText(YAMLFileType.YML, text); } protected final void configureWorkflowProjectFile(final String text) { + configureProjectFile(".github/workflows/workflow.yml", text); + } + + protected final void configureGiteaWorkflowProjectFile(final String text) { + configureProjectFile(".gitea/workflows/workflow.yml", text); + } + + protected final List completeGiteaWorkflow(final String text) { + configureGiteaWorkflowProjectFile(text); + return completeBasicLookupStrings(); + } + + private void configureProjectFile(final String path, final String text) { final int caretOffset = text.indexOf(""); final String fileText = text.replace("", ""); - myFixture.addFileToProject(".github/workflows/workflow.yml", fileText); - myFixture.configureFromTempProjectFile(".github/workflows/workflow.yml"); + myFixture.addFileToProject(path, fileText); + myFixture.configureFromTempProjectFile(path); if (caretOffset >= 0) { myFixture.getEditor().getCaretModel().moveToOffset(caretOffset); }