├── .eslintrc.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build-preview.yml │ ├── build.yml │ ├── publish.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── [Content_Types].xml ├── docs ├── development.md ├── images │ ├── lsp-illustration.png │ └── system-components.png └── project-architecture.md ├── jest.config.js ├── language ├── language-configuration.json └── syntaxes │ ├── expressions.tmGrammar.json │ └── yaml.tmLanguage.json ├── media ├── authoring.gif ├── docs.png ├── header.png ├── highlight.png ├── validation.png └── workflows.png ├── package-lock.json ├── package.json ├── release-notes.txt ├── resources ├── icons │ ├── dark │ │ ├── add.svg │ │ ├── edit.svg │ │ ├── explorer.svg │ │ ├── lang.svg │ │ ├── logs.svg │ │ ├── refresh.svg │ │ ├── remove.svg │ │ ├── run.svg │ │ ├── steps │ │ │ ├── step_cancelled.svg │ │ │ ├── step_failure.svg │ │ │ ├── step_inprogress.svg │ │ │ ├── step_queued.svg │ │ │ ├── step_skipped.svg │ │ │ ├── step_success.svg │ │ │ └── step_warning.svg │ │ └── workflowruns │ │ │ ├── wr_cancelled.svg │ │ │ ├── wr_failure.svg │ │ │ ├── wr_inprogress.svg │ │ │ ├── wr_pending.svg │ │ │ ├── wr_queued.svg │ │ │ ├── wr_skipped.svg │ │ │ ├── wr_success.svg │ │ │ ├── wr_waiting.svg │ │ │ └── wr_warning.svg │ └── light │ │ ├── add.svg │ │ ├── edit.svg │ │ ├── explorer.svg │ │ ├── lang.svg │ │ ├── logs.svg │ │ ├── refresh.svg │ │ ├── remove.svg │ │ ├── run.svg │ │ ├── steps │ │ ├── step_cancelled.svg │ │ ├── step_failure.svg │ │ ├── step_inprogress.svg │ │ ├── step_pending.svg │ │ ├── step_queued.svg │ │ ├── step_skipped.svg │ │ ├── step_success.svg │ │ └── step_warning.svg │ │ └── workflowruns │ │ ├── wr_cancelled.svg │ │ ├── wr_failure.svg │ │ ├── wr_inprogress.svg │ │ ├── wr_pending.svg │ │ ├── wr_queued.svg │ │ ├── wr_skipped.svg │ │ ├── wr_success.svg │ │ ├── wr_waiting.svg │ │ └── wr_warning.svg └── logo.png ├── script ├── bootstrap ├── workflows │ ├── generate-release-notes.sh │ └── increment-version.sh └── workspace │ ├── package-lock.json │ ├── package.json │ ├── update-package-locks.sh │ ├── vscode-github-actions.code-workspace │ └── watch.sh ├── src ├── api │ ├── api.ts │ ├── canReachGitHubAPI.ts │ └── handleSamlError.ts ├── auth │ └── auth.ts ├── commands │ ├── cancelWorkflowRun.ts │ ├── openWorkflowFile.ts │ ├── openWorkflowJobLogs.ts │ ├── openWorkflowRun.ts │ ├── openWorkflowStepLogs.ts │ ├── pinWorkflow.ts │ ├── rerunWorkflowRun.ts │ ├── secrets │ │ ├── addSecret.ts │ │ ├── copySecret.ts │ │ ├── deleteSecret.ts │ │ └── updateSecret.ts │ ├── signIn.ts │ ├── triggerWorkflowRun.ts │ ├── unpinWorkflow.ts │ └── variables │ │ ├── addVariable.ts │ │ ├── copyVariable.ts │ │ ├── deleteVariable.ts │ │ └── updateVariable.ts ├── configuration │ └── configuration.ts ├── extension.ts ├── external │ ├── README.md │ ├── protocol.ts │ └── ssh.ts ├── git │ ├── repository-permissions.ts │ └── repository.ts ├── globals.ts ├── langserver.ts ├── log.ts ├── logs │ ├── constants.ts │ ├── fileProvider.ts │ ├── foldingProvider.ts │ ├── formatProvider.ts │ ├── logInfo.ts │ ├── model.ts │ ├── parser.ts │ ├── scheme.ts │ └── symbolProvider.ts ├── model.ts ├── pinnedWorkflows │ └── pinnedWorkflows.ts ├── secrets │ ├── index.test.ts │ └── index.ts ├── store │ ├── WorkflowJob.ts │ ├── store.ts │ └── workflowRun.ts ├── tracker │ ├── workflowDocumentTracker.ts │ └── workspaceTracker.ts ├── treeViews │ ├── current-branch │ │ ├── currentBranchRepoNode.ts │ │ └── noRunForBranchNode.ts │ ├── currentBranch.ts │ ├── icons.ts │ ├── settings.ts │ ├── settings │ │ ├── emptyNode.ts │ │ ├── environmentNode.ts │ │ ├── environmentSecretsNode.ts │ │ ├── environmentVariablesNode.ts │ │ ├── environmentsNode.ts │ │ ├── orgSecretsNode.ts │ │ ├── orgVariablesNode.ts │ │ ├── repoSecretsNode.ts │ │ ├── repoVariablesNode.ts │ │ ├── secretNode.ts │ │ ├── secretsNode.ts │ │ ├── settingsRepoNode.ts │ │ ├── types.ts │ │ ├── variableNode.ts │ │ └── variablesNode.ts │ ├── shared │ │ ├── attemptNode.ts │ │ ├── authenticationNode.ts │ │ ├── errorNode.ts │ │ ├── gitHubApiUnreachableNode.ts │ │ ├── noGitHubRepositoryNode.ts │ │ ├── noWorkflowJobsNode.ts │ │ ├── previousAttemptsNode.ts │ │ ├── runTooltipHelper.ts │ │ ├── workflowJobNode.ts │ │ └── workflowRunNode.ts │ ├── treeViews.ts │ ├── workflowRunTreeDataProvider.ts │ ├── workflows.ts │ └── workflows │ │ ├── workflowNode.ts │ │ ├── workflowStepNode.ts │ │ └── workflowsRepoNode.ts ├── typings │ ├── git.d.ts │ └── ssh-config.d.ts └── workflow │ ├── documentSelector.ts │ ├── languageServer.ts │ └── workflow.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true 4 | }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | "prettier" 11 | ], 12 | "ignorePatterns": ["src/external"], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": "./tsconfig.json" 16 | }, 17 | "plugins": ["@typescript-eslint", "prettier"], 18 | "reportUnusedDisableDirectives": true, 19 | "root": true, 20 | "rules": {} 21 | } 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/actions-vscode-reviewers 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug or unexpected behavior while using the Actions VS Code extension 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. With this workflow '...' 16 | 2. Do this '...' 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Extension Version** 26 | `v1.x.y` 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a question or provide feedback about the Actions VS Code extension 4 | about: For general Q&A and feedback, see the Discussions tab. 5 | url: https://github.com/github/vscode-github-actions/discussions 6 | - name: Ask a question or provide feedback about GitHub Actions 7 | about: Please check out the GitHub community forum for discussions about GitHub Actions 8 | url: https://github.com/orgs/community/discussions/categories/actions 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for the Actions VS Code extension 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | registries: 3 | npm-github: 4 | type: npm-registry 5 | url: https://npm.pkg.github.com 6 | token: ${{secrets.DEPENDABOT_TOKEN}} 7 | updates: 8 | - package-ecosystem: "npm" 9 | directories: 10 | - "/" 11 | - "script/workspace" 12 | schedule: 13 | interval: "weekly" 14 | registries: 15 | - npm-github 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /.github/workflows/build-preview.yml: -------------------------------------------------------------------------------- 1 | name: Build Preview version 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | packages: read 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 16.x 22 | cache: "npm" 23 | registry-url: "https://npm.pkg.github.com" 24 | - run: npm --no-git-tag-version version 1.0.${{ github.run_number }} 25 | - run: npm install @actions/languageserver@latest @actions/workflow-parser@latest @actions/expressions@latest @actions/languageservice@latest 26 | - run: npm ci 27 | - name: create a package.json without scoped name 28 | run: | 29 | cp package.json package.json.real 30 | sed --regexp-extended '/"name"\s*:/ s#@[a-zA-Z\\-]+/##' package.json.real > package.json 31 | - run: npm run package 32 | - uses: actions/upload-artifact@v4 33 | with: 34 | path: ./vscode-github-actions-1.0.${{ github.run_number }}.vsix 35 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: read 11 | packages: read 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 16.x 21 | cache: "npm" 22 | registry-url: "https://npm.pkg.github.com" 23 | - run: npm ci 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | - run: npm run format-check 27 | - run: npm run lint 28 | - run: npm run build 29 | - run: npm run test 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release PR 2 | 3 | run-name: Create release PR for new ${{ github.event.inputs.version }} version 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | version: 9 | required: true 10 | type: choice 11 | description: "What type of release is this" 12 | options: 13 | - "major" 14 | - "minor" 15 | - "patch" 16 | update-language-server: 17 | required: true 18 | description: "Update the language server to the latest version?" 19 | type: boolean 20 | 21 | 22 | jobs: 23 | create-release-pr: 24 | name: Create release PR 25 | 26 | runs-on: ubuntu-latest 27 | 28 | permissions: 29 | contents: write 30 | pull-requests: write 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - uses: actions/setup-node@v4 36 | with: 37 | node-version: "16" 38 | 39 | - name: Bump version and push 40 | run: | 41 | git config --global user.email "github-actions@github.com" 42 | git config --global user.name "GitHub Actions" 43 | 44 | NEW_VERSION=$(./script/workflows/increment-version.sh ${{ inputs.version }}) 45 | 46 | git checkout -b release/$NEW_VERSION 47 | 48 | npm version $NEW_VERSION --no-git-tag-version 49 | git add package.json package-lock.json 50 | git commit -m "Release extension version $NEW_VERSION" 51 | 52 | git push --set-upstream origin release/$NEW_VERSION 53 | 54 | echo "new_version=$NEW_VERSION" >> $GITHUB_ENV 55 | 56 | - name: Update language server 57 | if: ${{ inputs.update-language-server }} 58 | run: | 59 | npm install @actions/languageserver@latest @actions/workflow-parser@latest --workspaces=false 60 | git checkout -- package.json 61 | npm i 62 | 63 | - uses: stefanzweifel/git-auto-commit-action@3ea6ae190baf489ba007f7c92608f33ce20ef04a 64 | with: 65 | branch: release/${{ env.new_version }} 66 | if: ${{ inputs.update-language-server }} 67 | 68 | - name: Create PR 69 | run: | 70 | LAST_PR=$(gh pr list --repo ${{ github.repository }} --limit 1 --state merged --search "Release version" --json number | jq -r '.[0].number') 71 | ./script/workflows/generate-release-notes.sh $LAST_PR ${{ env.new_version }} 72 | gh pr create \ 73 | --title "Release version ${{ env.new_version }}" \ 74 | --body-file releasenotes.md \ 75 | --base main \ 76 | --head release/${{ env.new_version }} 77 | env: 78 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | dist 4 | *.vsix 5 | .DS_Store 6 | runners 7 | .yalc -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | dist 4 | *.vsix 5 | .DS_Store 6 | runners 7 | .yalc 8 | *.md -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "printWidth": 120, 5 | "singleQuote": false, 6 | "bracketSpacing": false, 7 | "trailingComma": "none", 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Watch & Launch extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 9 | "preLaunchTask": "npm: watch", 10 | "smartStep": true, 11 | "sourceMaps": true, 12 | "resolveSourceMapLocations": [] 13 | }, 14 | { 15 | "type": "node", 16 | "request": "attach", 17 | "name": "Attach to Language-Server", 18 | "address": "localhost", 19 | "protocol": "inspector", 20 | "port": 6010, 21 | "smartStep": true, 22 | "sourceMaps": true 23 | }, 24 | { 25 | "type": "node", 26 | "request": "attach", 27 | "name": "Attach to language-server with delay", 28 | "address": "localhost", 29 | "protocol": "inspector", 30 | "port": 6010, 31 | "smartStep": true, 32 | "sourceMaps": true, 33 | "preLaunchTask": "delay" 34 | }, 35 | { 36 | "name": "Run Web Extension in VS Code", 37 | "type": "extensionHost", 38 | "debugWebWorkerHost": true, 39 | "request": "launch", 40 | "args": ["--extensionDevelopmentPath=${workspaceFolder}", "--extensionDevelopmentKind=web"], 41 | "outFiles": ["${workspaceFolder}/dist/web/**/*.js"], 42 | "preLaunchTask": "npm: watch" 43 | } 44 | ], 45 | "compounds": [ 46 | { 47 | "name": "Watch & Launch extension + language-server", 48 | "configurations": ["Watch & Launch extension", "Attach to language-server with delay"] 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "github-actions.workflows.pinned.workflows": [".github/workflows/build.yml"], 3 | "editor.formatOnSave": true, 4 | "[typescript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "group": "build", 10 | "isBackground": true, 11 | "problemMatcher": ["$ts-webpack-watch"] 12 | }, 13 | { 14 | "label": "delay", 15 | "type": "shell", 16 | "command": "sleep 5", 17 | "windows": { 18 | "command": "ping 127.0.0.1 -n 5 > nul" 19 | }, 20 | "group": "none", 21 | "presentation": { 22 | "reveal": "silent", 23 | "panel": "dedicated", 24 | "close": true 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .devcontainer 2 | .vscode 3 | node_modules 4 | out/ 5 | src/ 6 | tsconfig.json 7 | webpack.config.js 8 | media 9 | dist/*.map -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions for VS Code 2 | 3 | The GitHub Actions extension lets you manage your workflows, view the workflow run history, and helps with authoring workflows. 4 | 5 | ![](./media/header.png) 6 | 7 | ## Features 8 | 9 | ### Manage workflows and runs 10 | 11 | * Manage your workflows and runs without leaving your editor. 12 | * Keep track of your CI builds and deployments. 13 | * Investigate failures and view logs. 14 | 15 | ![View workflow runs and logs](./media/workflows.png) 16 | 17 | ### Workflow authoring 18 | 19 | Be more confident when authoring and modifying workflows. Find errors before committing workflows with: 20 | 21 | **Syntax highlighting** for workflows and GitHub Actions Expressions makes it clear where values are inserted at execution time: 22 | 23 | ![Syntax highlighting](media/highlight.png) 24 | 25 | **Integrated documentation** for the workflow schema, expression functions, and even event payloads: 26 | 27 | ![Tooltip showing description for a pull_request payload](media/docs.png) 28 | 29 | **Validation and code completion** for the YAML schema and GitHub Actions Expressions. Get instant validation and code completion for the workflow schema, expression functions, event payloads, and job or step `outputs`: 30 | 31 | ![Validation for YAML keys and expressions](media/validation.png) 32 | 33 | **Smart validation and code completion for actions and reusable workflows**: the extension automatically parses parameters, inputs, and outputs for referenced actions and called reusable workflows for code-completion and validation. 34 | 35 | ![Video showing workflow validation and auto-completion](./media/authoring.gif) 36 | 37 | ## Getting started 38 | 39 | 1. Install the extension from the [Marketplace](https://marketplace.visualstudio.com/items?itemName=github.vscode-github-actions). 40 | 1. Sign in with your GitHub account and when prompted allow `GitHub Actions` access to your GitHub account. 41 | 1. Open a GitHub repository. 42 | 1. You will be able to utilize the syntax features in Workflow files, and you can find the GitHub Actions icon on the left navigation to manage your Workflows. 43 | 44 | 45 | ## Supported Features 46 | 47 | - Manage your workflows and runs 48 | - Edit workflows (syntax highlighting, auto-completion, hovering, and validation) 49 | - Keep track of your CI builds and deployments 50 | - Investigate failures and view logs 51 | - Modify settings like Environments, Secrets, and Variables 52 | 53 | Unfortunately, at this time we are not able to support the extension with [remote repositories](https://docs.github.com/en/get-started/getting-started-with-git/about-remote-repositories) (including [github.dev](https://github.dev/) and [vscode.dev](https://vscode.dev/)), so please use the extension with locally downloaded GitHub repositories for the best experience. Please check back here for updates in the future! 54 | 55 | We have enabled experimental functionality to support GitHub Enterprise Server, but this feature is an experimental beta and currently unsupported. To try this out, enable the `use-enterprise` setting to authenticate with your `GitHub Enterprise Server Authentication Provider` settings 56 | Use-enterprise setting checkbox 57 | 58 | We currently do not have the capability to support Operating System (OS) certificates or enterprise proxies (we plan to support pulling from the VS Code proxy settings), but we have plans for it in the future and it is on our backlog! 59 | 60 | ## Contributing 61 | 62 | See [CONTRIBUTING.md](CONTRIBUTING.md). A description of the architecture of the extension can be found [here](./docs/project-architecture.md). 63 | 64 | ## License 65 | 66 | This project is licensed under the terms of the MIT open source license. Please refer to [MIT](LICENSE) for the full terms. 67 | -------------------------------------------------------------------------------- /[Content_Types].xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Workspaces 4 | 5 | It's recommended to use [npm workspaces](https://docs.npmjs.com/cli/v8/using-npm/workspaces) to allow testing changes to multiple packages. On Codespaces, this is already set up. 6 | 7 | npm workspaces are really meant for mono-repos, but we can use that feature here to link our various packages together so you can make changes to a package and immediately consume those changes in a dependent package. 8 | 9 | ## Local Setup 10 | 11 | The workspace files will be in the parent directory of the repository, so it's recommended to create a folder for all of the workspace repositories. We chose `vscode` for these instructions. 12 | 13 | ```shell 14 | mkdir ~/vscode 15 | cd ~/vscode 16 | ``` 17 | 18 | Then, clone this repository and run `script/bootstrap` to pull in the other repositories. 19 | 20 | ```shell 21 | gh repo clone github/vscode-github-actions 22 | cd vscode-github-actions 23 | script/bootstrap 24 | ``` 25 | 26 | Finally, install packages in the workspace and build 27 | 28 | ```shell 29 | cd ~/vscode 30 | npm i 31 | npm run build -ws 32 | ``` 33 | 34 | **Note**: We have included a `package-lock.json` in `script/workspace`. If `npm run build -ws` fails because packages are not installed correctly with `npm i`, re-run `script/bootstrap` and run `npm ci` to 35 | get working packages. 36 | 37 | ## Make changes 38 | 39 | 1. Open the workspace in VS Code `File -> Open Workspace from File...`: `/workspaces/vscode-github-actions.code-workspace` 40 | - If you're doing local development, replace `/workspaces` with the folder you created above (`~/vscode` in the example) 41 | 1. Make change to any of the packages 42 | 1. Build them all with `npm run build -ws` in `/workspaces/` (or `~/vscode/` for local dev) 43 | 1. Uninstall or disable the Actions extension in your development instance of VS Code 44 | 1. Start and debug extension with the `Watch & Launch Extension` configuration from the "Run and Debug" side panel menu 45 | 1. Open a workspace in the remote extension host that contains workflow files in the `.github/workflows` directory 46 | 47 | ### Updating dependencies 48 | 49 | Once you're happy with your changes, publish the changes to the respective packages. You might have to adjust package versions, so if you made a change to `actions-workflow-parser` and increase the version there, you will have to consume the updated package in `actions-languageservice`. 50 | 51 | `npm workspaces` hoists all dependencies into a shared `node_modules` folder at the root directory (`/workspaces/node_modules` or `~/vscode/node_modules` for local dev) and also creates a single `package-lock.json` file there for the whole workspace. We don't want that when pushing changes back to the individual repos. 52 | 53 | There is a script in `/workspaces` (or `~/vscode` for local dev): `update-package-locks.sh` that does an `npm install` in every package directory _without_ using workspaces. That way, the local `package-lock.json` file is generated correctly and can be pushed to the repository. 54 | 55 | ## Debugging 56 | 57 | Launching and debugging the extension should just work. If you need to debug the language server, start the extension first, then execute the `Attach to language-server` target to also attach to the language server. 58 | 59 | ## Troubleshooting 60 | 61 | ### npm error: "This command does not support workspaces" 62 | 63 | Upgrade to a newer version of npm. You can use `npm install npm@latest -g`, `brew upgrade node` or other methods. 64 | -------------------------------------------------------------------------------- /docs/images/lsp-illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/docs/images/lsp-illustration.png -------------------------------------------------------------------------------- /docs/images/system-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/docs/images/system-components.png -------------------------------------------------------------------------------- /docs/project-architecture.md: -------------------------------------------------------------------------------- 1 | # Project Architecture 2 | 3 | This is a high-level overview of the Visual Studio Code Extension. The intended audience is someone interested in contributing the extension. 4 | 5 | ## Background 6 | 7 | Visual Studio Code provides an [API](https://code.visualstudio.com/api/language-extensions/overview) for implementing language-specific editing features in an extension. In VS Code, a language editor extension has two main components: 8 | 9 | - a **language client** which is a normal VS Code extension that listens to different editor events and sends the payloads of these events to a language server 10 | - a **language server** which is performs language analysis on the code written within VSCode (this runs in a separate process for performance reasons) 11 | 12 | The language client and server communicate via the Language Server Protocol. The [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) defines a standardized communication protocol between an editor (in our case Visual Studio Code) and a language server that implements features like autocomplete and syntax checking. 13 | 14 | ![LSP Illustration](./images/lsp-illustration.png) 15 | 16 | By making the language server implementation LSP compliant we have set ourselves up to integrate with other text editors in the future. 17 | 18 | Fundamentally, the extension takes in a string of text (YAML specifying a GitHub Actions workflow) and needs to make sense of the text. Conceptually, what this extension does is similar to what other compilers and interpreters (e.g., [LLVM](https://aosabook.org/en/v1/llvm.html)) do. The basic steps form a pipeline, with the output of one step serving as the input to the next step. Broadly speaking the steps involved are: 19 | 20 | 1. A lexer takes in a stream of text (in this case a workflow file) and breaks the text into the atomic chunks of the language called tokens 21 | 2. Next, the tokens are fed into the parser which constructs a tree representation of the program 22 | 3. Lastly, the tree is evaluated. This step takes the syntax tree as input and produces a literal value. 23 | 24 | _Crafting Interpreters_ - which can be read freely online [here](https://craftinginterpreters.com/contents.html) - goes into much greater depth about compiler/interpreter development. 25 | 26 | ## Architecture 27 | 28 | The [VS Code extension](https://github.com/github/vscode-github-actions) integrates with Visual Studio code and uses [open-source libraries](https://github.com/actions/languageservices/tree/main) in order to provide its full functionality. The extension initializes the language server and performs other non-language related tasks like adding functionality to the VS Code sidebar. 29 | 30 | The open-source language libraries that the extension uses are: 31 | 32 | - The [language server](https://github.com/actions/languageservices/tree/main/languageserver) library is a wrapper around the language service 33 | - it handles the connection with VS Code via the Language Service Protocol 34 | - makes API calls to GitHub (e.g., requesting [Action secrets](https://docs.github.com/en/rest/actions/secrets?apiVersion=2022-11-28)) for repository and workflow information 35 | - The [language service](https://github.com/actions/languageservices/tree/main/languageservice) library uses the workflow parser and expression engine (described below) to implement the core functionality of the extension 36 | - it calls into the language server for any data that requires an API call 37 | - the [workflow-parser](https://github.com/actions/languageservices/tree/main/workflow-parser) library parses GitHub Actions workflows into an intermediate representation and validates that the workflow file is syntactically valid 38 | - the workflow parser uses a [schema](https://github.com/actions/languageservices/blob/main/workflow-parser/src/workflow-v1.0.json) to parse the workflow file 39 | - the schema defines the list of valid tokens and their arguments 40 | - the [expressions](https://github.com/actions/languageservices/tree/main/expressions) engine is used to parse and evaluate GitHub Actions [expressions](https://docs.github.com/en/actions/learn-github-actions/expressions) 41 | 42 | ![system-components](./images/system-components.png) 43 | 44 | ## Useful Links 45 | 46 | - [actions/languageservices](https://github.com/actions/languageservices) is the monorepo where all language services code for GitHub Actions can be found 47 | - [GitHub Actions Expression Language Documentation](https://docs.github.com/en/actions/learn-github-actions/expressions) -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest/presets/default-esm", 4 | moduleNameMapper: { 5 | "^(\\.{1,2}/.*)\\.js$": "$1" 6 | }, 7 | transform: { 8 | "^.+\\.tsx?$": [ 9 | "ts-jest", 10 | { 11 | useESM: true 12 | } 13 | ] 14 | }, 15 | moduleFileExtensions: ["ts", "js"] 16 | }; 17 | -------------------------------------------------------------------------------- /language/language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "#" 4 | }, 5 | "brackets": [ 6 | ["{", "}"], 7 | ["[", "]"], 8 | ["(", ")"] 9 | ], 10 | "autoClosingPairs": [ 11 | ["{", "}"], 12 | ["[", "]"], 13 | ["(", ")"], 14 | ["\"", "\""], 15 | ["'", "'"] 16 | ], 17 | "surroundingPairs": [ 18 | ["{", "}"], 19 | ["[", "]"], 20 | ["(", ")"], 21 | ["\"", "\""], 22 | ["'", "'"] 23 | ], 24 | "colorizedBracketPairs": [["${{", "}}"]], 25 | "folding": { 26 | "offSide": true, 27 | "markers": { 28 | "start": "^\\s*#\\s*region\\b", 29 | "end": "^\\s*#\\s*endregion\\b" 30 | } 31 | }, 32 | "indentationRules": { 33 | "increaseIndentPattern": "^\\s*.*(:|-) ?(&\\w+)?(\\{[^}\"']*|\\([^)\"']*)?$", 34 | "decreaseIndentPattern": "^\\s+\\}$" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /language/syntaxes/expressions.tmGrammar.json: -------------------------------------------------------------------------------- 1 | { 2 | "scopeName": "source.github-actions-workflow.github-actions-expression", 3 | "injectionSelector": "L:source.github-actions-workflow", 4 | "patterns": [ 5 | { 6 | "include": "#expression" 7 | }, 8 | { 9 | "include": "#if-expression" 10 | } 11 | ], 12 | "repository": { 13 | "expression": { 14 | "match": "[|-]?\\$\\{\\{(.*?)\\}\\}", 15 | "name": "meta.embedded.block.github-actions-expression", 16 | "captures": { 17 | "1": { 18 | "patterns": [ 19 | { 20 | "include": "#function-call" 21 | }, 22 | { 23 | "include": "#context" 24 | }, 25 | { 26 | "include": "#string" 27 | }, 28 | { 29 | "include": "#number" 30 | }, 31 | { 32 | "include": "#boolean" 33 | }, 34 | { 35 | "include": "#null" 36 | } 37 | ] 38 | } 39 | } 40 | }, 41 | "if-expression": { 42 | "match": "\\b(if:) (.*?)$", 43 | "contentName": "meta.embedded.block.github-actions-expression", 44 | "captures": { 45 | "1": { 46 | "patterns": [ 47 | { 48 | "include": "source.github-actions-workflow" 49 | } 50 | ] 51 | }, 52 | "2": { 53 | "patterns": [ 54 | { 55 | "include": "#function-call" 56 | }, 57 | { 58 | "include": "#context" 59 | }, 60 | { 61 | "include": "#string" 62 | }, 63 | { 64 | "include": "#number" 65 | }, 66 | { 67 | "include": "#boolean" 68 | }, 69 | { 70 | "include": "#null" 71 | } 72 | ] 73 | } 74 | } 75 | }, 76 | "function-call": { 77 | "patterns": [ 78 | { 79 | "match": "\\b([A-Za-z]*)\\(", 80 | "captures": { 81 | "1": { 82 | "name": "support.function.github-actions-expression" 83 | } 84 | } 85 | } 86 | ] 87 | }, 88 | "context": { 89 | "patterns": [ 90 | { 91 | "name": "variable.other.read.github-actions-expression", 92 | "match": "\\b[A-Za-z][A-Za-z0-9_\\-]*\\b" 93 | } 94 | ] 95 | }, 96 | "string": { 97 | "name": "string.quoted.single.github-actions-expression", 98 | "begin": "'", 99 | "end": "'" 100 | }, 101 | "number": { 102 | "name": "constant.numeric.github-actions-expression", 103 | "match": "\\b[0-9]+(?:.[0-9]+)?\\b" 104 | }, 105 | "boolean": { 106 | "name": "constant.language.boolean.github-actions-expression", 107 | "match": "\\b(true|false)\\b" 108 | }, 109 | "null": { 110 | "name": "constant.language.null.github-actions-expression", 111 | "match": "\\bnull\\b" 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /media/authoring.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/media/authoring.gif -------------------------------------------------------------------------------- /media/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/media/docs.png -------------------------------------------------------------------------------- /media/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/media/header.png -------------------------------------------------------------------------------- /media/highlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/media/highlight.png -------------------------------------------------------------------------------- /media/validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/media/validation.png -------------------------------------------------------------------------------- /media/workflows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/media/workflows.png -------------------------------------------------------------------------------- /release-notes.txt: -------------------------------------------------------------------------------- 1 | Release 0.25.7 2 | - Add working lock file and update script to add it to the npm workspace root \n 3 | - Make pre-prepare run off of hooks \n 4 | - @muzimuzhi - Upgrade vsce 2.11.0 to @vscode/vsce version 2.19.0 \n 5 | - Update workflow file \n 6 | - Release version 0.25.6 \n 7 | -------------------------------------------------------------------------------- /resources/icons/dark/add.svg: -------------------------------------------------------------------------------- 1 | Layer 1 -------------------------------------------------------------------------------- /resources/icons/dark/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/explorer.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/dark/lang.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/dark/logs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/dark/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/run.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/dark/steps/step_cancelled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/steps/step_failure.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/dark/steps/step_inprogress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/icons/dark/steps/step_queued.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/dark/steps/step_skipped.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/dark/steps/step_success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/steps/step_warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_cancelled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_failure.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_inprogress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_pending.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_queued.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_skipped.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_waiting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/dark/workflowruns/wr_warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/add.svg: -------------------------------------------------------------------------------- 1 | Layer 1 -------------------------------------------------------------------------------- /resources/icons/light/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/explorer.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/light/lang.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/logs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/light/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/run.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_cancelled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_failure.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_inprogress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_pending.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_queued.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_skipped.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/steps/step_warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_cancelled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_failure.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_inprogress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_pending.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_queued.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_skipped.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_waiting.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/light/workflowruns/wr_warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/vscode-github-actions/8850a664b7dfd05efca1e00adc0d338f5ce296ab/resources/logo.png -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | function clone_repo() { 5 | GREEN='\033[0;32m' 6 | 7 | REPOSITORY_URL=$1 8 | REPOSITORY_PATH=$2 9 | if [[ ! -d "$REPOSITORY_PATH/.git" ]]; then 10 | echo -e "\n${GREEN}➡️ Cloning $REPOSITORY_URL...\n${NC}" 11 | git clone "$REPOSITORY_URL" "$REPOSITORY_PATH" 12 | fi 13 | } 14 | 15 | repo_root="$(git rev-parse --show-toplevel)" 16 | root="$(dirname "$repo_root")" 17 | 18 | # Copy working package-lock.json to workspace 19 | echo "Copy lock file to workspace..." 20 | cp "$repo_root"/script/workspace/package-lock.json "$root" 21 | 22 | # Clone dependent repos 23 | echo "Cloning dependent repos..." 24 | clone_repo https://github.com/actions/languageservices "$root"/languageservices 25 | 26 | # Copy workspace files 27 | echo "Copying workspace files..." 28 | cp "$repo_root"/script/workspace/package.json "$root" 29 | cp "$repo_root"/script/workspace/vscode-github-actions.code-workspace "$root" 30 | cp "$repo_root"/script/workspace/update-package-locks.sh "$root" 31 | cp "$repo_root"/script/workspace/watch.sh "$root" 32 | -------------------------------------------------------------------------------- /script/workflows/generate-release-notes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # this script is used to generate release notes for a given release 3 | # first argument is the pull request id for the last release 4 | # the second is the new release number 5 | 6 | # the script then grabs every pull request merged since that pull request 7 | # and outputs a string of release notes 8 | 9 | echo "Generating release notes for $2" 10 | 11 | # get the last release pull request id 12 | LAST_RELEASE_PR=$1 13 | 14 | # get the new release number 15 | NEW_RELEASE=$2 16 | 17 | #get when the last release was merged 18 | LAST_RELEASE_MERGED_AT=$(gh pr view $LAST_RELEASE_PR --repo github/vscode-github-actions --json mergedAt | jq -r '.mergedAt') 19 | 20 | CHANGELIST=$(gh pr list --repo github/vscode-github-actions --base main --state merged --json title --search "merged:>$LAST_RELEASE_MERGED_AT -label:no-release") 21 | 22 | # store the release notes in a variable so we can use it later 23 | 24 | echo "Release $NEW_RELEASE" >> releasenotes.md 25 | 26 | echo $CHANGELIST | jq -r '.[].title' | while read line; do 27 | echo " - $line" >> releasenotes.md 28 | done 29 | 30 | echo " " -------------------------------------------------------------------------------- /script/workflows/increment-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION=$(cat package.json | jq -r '.version') 4 | 5 | MAJOR=$(echo $VERSION | cut -d. -f1) 6 | MINOR=$(echo $VERSION | cut -d. -f2) 7 | PATCH=$(echo $VERSION | cut -d. -f3) 8 | 9 | if [ "$1" == "major" ]; then 10 | MAJOR=$((MAJOR+1)) 11 | MINOR=0 12 | PATCH=0 13 | elif [ "$1" == "minor" ]; then 14 | MINOR=$((MINOR+1)) 15 | PATCH=0 16 | elif [ "$1" == "patch" ]; then 17 | PATCH=$((PATCH+1)) 18 | else 19 | echo "Invalid version type. Use 'major', 'minor' or 'patch'" 20 | exit 1 21 | fi 22 | 23 | NEW_VERSION="$MAJOR.$MINOR.$PATCH" 24 | echo $NEW_VERSION 25 | -------------------------------------------------------------------------------- /script/workspace/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "./languageservices/expressions", 5 | "./languageservices/workflow-parser", 6 | "./languageservices/languageservice", 7 | "./languageservices/languageserver", 8 | "./vscode-github-actions" 9 | ], 10 | "dependencies": { 11 | "@babel/traverse": "^7.24.7" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /script/workspace/update-package-locks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | for DIR in "languageservices" "vscode-github-actions" 5 | do 6 | pushd $DIR 7 | # Trigger an npm i without workspaces support to update the local package-lock.json 8 | npm i --workspaces=false 9 | rm -fr ./node_modules 10 | popd 11 | done 12 | 13 | npm i 14 | -------------------------------------------------------------------------------- /script/workspace/vscode-github-actions.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | {"path": "./languageservices/expressions"}, 4 | {"path": "./languageservices/workflow-parser"}, 5 | {"path": "./languageservices/languageservice"}, 6 | {"path": "./languageservices/languageserver"}, 7 | {"path": "./vscode-github-actions"} 8 | ], 9 | "extensions": { 10 | "recommendations": ["firsttris.vscode-jest-runner", "Orta.vscode-jest"] 11 | }, 12 | "settings": { 13 | "jestrunner.jestCommand": "npm test --", 14 | "jestrunner.debugOptions": { 15 | "program": "${workspaceFolder:vscode-github-actions}/../node_modules/jest/bin/jest.js", 16 | "env": { 17 | "NODE_OPTIONS": "--experimental-vm-modules" 18 | } 19 | }, 20 | "jest.jestCommandLine": "npm test --", 21 | "jest.nodeEnv": { 22 | "NODE_OPTIONS": "--experimental-vm-modules" 23 | } 24 | }, 25 | "launch": { 26 | "version": "0.2.0", 27 | "configurations": [ 28 | { 29 | "name": "Watch all & Launch extension", 30 | "type": "extensionHost", 31 | "request": "launch", 32 | "args": ["--extensionDevelopmentPath=${workspaceFolder:vscode-github-actions}"], 33 | "cwd": "${workspaceFolder:vscode-github-actions}", 34 | "preLaunchTask": "watch", 35 | "smartStep": true, 36 | "sourceMaps": true, 37 | "resolveSourceMapLocations": [] 38 | } 39 | ], 40 | "compounds": [ 41 | { 42 | "name": "Watch all & Launch extension + language-server", 43 | "configurations": ["Watch all & Launch extension", "Attach to language-server with delay"] 44 | } 45 | ] 46 | }, 47 | "tasks": { 48 | "version": "2.0.0", 49 | "tasks": [ 50 | { 51 | "label": "watch", 52 | "command": "./watch.sh", 53 | "options": { 54 | "cwd": "${workspaceFolder:vscode-github-actions}/.." 55 | }, 56 | "type": "shell", 57 | "isBackground": true, 58 | "problemMatcher": ["$ts-webpack-watch", "$tsc"], 59 | "presentation": { 60 | "reveal": "always", 61 | "clear": true, 62 | "close": true 63 | }, 64 | "group": "build" 65 | }, 66 | { 67 | "label": "update package-lock", 68 | "command": "./update-package-locks.sh", 69 | "options": { 70 | "cwd": "${workspaceFolder:vscode-github-actions}/.." 71 | }, 72 | "type": "shell", 73 | "problemMatcher": ["$ts-webpack-watch", "$tsc"], 74 | "presentation": { 75 | "reveal": "always", 76 | "clear": true, 77 | "close": true 78 | }, 79 | "group": "build" 80 | } 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /script/workspace/watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # allow killing all processes with ctrl-c 4 | trap 'kill 0' SIGINT 5 | 6 | for DIR in "languageservices/expressions" "languageservices/workflow-parser" "languageservices/languageservice" "languageservices/languageserver" "vscode-github-actions" 7 | do 8 | pushd $DIR 9 | npm run watch & 10 | popd 11 | done 12 | 13 | wait 14 | -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | import {Octokit} from "@octokit/rest"; 2 | import {version} from "../../package.json"; 3 | import {getGitHubApiUri} from "../configuration/configuration"; 4 | 5 | export const userAgent = `VS Code GitHub Actions (${version})`; 6 | 7 | export function getClient(token: string): Octokit { 8 | return new Octokit({ 9 | auth: token, 10 | userAgent: userAgent, 11 | baseUrl: getGitHubApiUri() 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/api/canReachGitHubAPI.ts: -------------------------------------------------------------------------------- 1 | import {TTLCache} from "@actions/languageserver/utils/cache"; 2 | 3 | import {getSession} from "../auth/auth"; 4 | import {logError} from "../log"; 5 | import {getClient} from "./api"; 6 | 7 | const API_ACCESS_TTL_MS = 10 * 1000; 8 | const cache = new TTLCache(API_ACCESS_TTL_MS); 9 | 10 | export async function canReachGitHubAPI() { 11 | const session = await getSession(); 12 | if (!session) { 13 | return false; 14 | } 15 | return await cache.get("canReachGitHubAPI", undefined, async () => { 16 | try { 17 | const octokit = getClient(session.accessToken); 18 | await octokit.request("GET /"); 19 | } catch (e) { 20 | logError(e as Error, "Error getting GitHub context"); 21 | return false; 22 | } 23 | return true; 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/api/handleSamlError.ts: -------------------------------------------------------------------------------- 1 | import {Octokit} from "@octokit/rest"; 2 | import {AuthenticationSession} from "vscode"; 3 | 4 | import {newSession} from "../auth/auth"; 5 | import {logDebug} from "../log"; 6 | import {getClient} from "./api"; 7 | 8 | export async function handleSamlError( 9 | session: AuthenticationSession, 10 | request: (client: Octokit) => Promise 11 | ): Promise { 12 | try { 13 | const client = getClient(session.accessToken); 14 | return await request(client); 15 | } catch (error) { 16 | if ((error as Error).message.includes("Resource protected by organization SAML enforcement.")) { 17 | logDebug("SAML error, re-authenticating"); 18 | const session = await newSession( 19 | "Your organization is protected by SAML enforcement. Please sign-in again to continue." 20 | ); 21 | const client = getClient(session.accessToken); 22 | return await request(client); 23 | } else { 24 | throw error; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {useEnterprise} from "../configuration/configuration"; 3 | 4 | const AUTH_PROVIDER_ID = "github"; 5 | const AUTH_PROVIDER_ID_ENTERPRISE = "github-enterprise"; 6 | const DEFAULT_SCOPES = ["repo", "workflow"]; 7 | 8 | let signInPrompted = false; 9 | 10 | const SESSION_ERROR = "Could not get token from the GitHub authentication provider.\nPlease sign in and allow access."; 11 | 12 | /** 13 | * Creates a session from the GitHub authentication provider 14 | * @param forceMessage Prompt to the user when forcing a new session 15 | * @returns A {@link vscode.AuthenticationSession} 16 | */ 17 | export async function newSession(forceMessage: string): Promise { 18 | const session = await getSessionInternal(forceMessage); 19 | if (session) { 20 | return session; 21 | } 22 | throw new Error(SESSION_ERROR); 23 | } 24 | 25 | /** 26 | * Retrieves a session from the GitHub authentication provider or prompts the user to sign in 27 | * @returns A {@link vscode.AuthenticationSession} or undefined 28 | */ 29 | export async function getSession(skipPrompt = false): Promise { 30 | const session = await getSessionInternal(skipPrompt); 31 | if (session) { 32 | await vscode.commands.executeCommand("setContext", "github-actions.signed-in", true); 33 | return session; 34 | } 35 | 36 | if (signInPrompted || skipPrompt) { 37 | return undefined; 38 | } 39 | 40 | signInPrompted = true; 41 | const signInAction = "Sign in to GitHub"; 42 | vscode.window 43 | .showInformationMessage("Sign in to GitHub to access your repositories and GitHub Actions workflows.", signInAction) 44 | .then( 45 | async result => { 46 | if (result === signInAction) { 47 | const session = await getSessionInternal(true); 48 | if (session) { 49 | await vscode.commands.executeCommand("setContext", "github-actions.signed-in", true); 50 | } 51 | } 52 | }, 53 | () => { 54 | // Ignore rejected promise 55 | } 56 | ); 57 | 58 | // User chose to not sign in or hasn't signed in yet 59 | return undefined; 60 | } 61 | 62 | async function getSessionInternal(forceNewMessage: string): Promise; 63 | async function getSessionInternal(createIfNone: boolean): Promise; 64 | async function getSessionInternal( 65 | createOrForceMessage: string | boolean = false 66 | ): Promise { 67 | // forceNewSession and createIfNone are mutually exclusive 68 | const options: vscode.AuthenticationGetSessionOptions = 69 | typeof createOrForceMessage === "string" 70 | ? {forceNewSession: {detail: createOrForceMessage}} 71 | : {createIfNone: createOrForceMessage}; 72 | const authProviderId = useEnterprise() ? AUTH_PROVIDER_ID_ENTERPRISE : AUTH_PROVIDER_ID; 73 | return await vscode.authentication.getSession(authProviderId, getScopes(), options); 74 | } 75 | 76 | function getScopes(): string[] { 77 | return DEFAULT_SCOPES; 78 | } 79 | -------------------------------------------------------------------------------- /src/commands/cancelWorkflowRun.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {WorkflowRunCommandArgs} from "../treeViews/shared/workflowRunNode"; 3 | 4 | export function registerCancelWorkflowRun(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.workflow.run.cancel", async (args: WorkflowRunCommandArgs) => { 7 | const gitHubRepoContext = args.gitHubRepoContext; 8 | const run = args.run; 9 | 10 | try { 11 | await gitHubRepoContext.client.actions.cancelWorkflowRun({ 12 | owner: gitHubRepoContext.owner, 13 | repo: gitHubRepoContext.name, 14 | run_id: run.run.id 15 | }); 16 | } catch (e) { 17 | await vscode.window.showErrorMessage(`Could not cancel workflow: '${(e as Error).message}'`); 18 | } 19 | 20 | // Start refreshing the run to reflect cancellation in UI 21 | args.store.pollRun(run.run.id, gitHubRepoContext, 1000, 10); 22 | }) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/openWorkflowFile.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {GitHubRepoContext} from "../git/repository"; 4 | import {Workflow} from "../model"; 5 | import {getWorkflowUri} from "../workflow/workflow"; 6 | 7 | interface OpenWorkflowCommandArgs { 8 | gitHubRepoContext: GitHubRepoContext; 9 | wf: Workflow; 10 | } 11 | 12 | export function registerOpenWorkflowFile(context: vscode.ExtensionContext) { 13 | context.subscriptions.push( 14 | vscode.commands.registerCommand( 15 | "github-actions.explorer.openWorkflowFile", 16 | async (args: OpenWorkflowCommandArgs) => { 17 | const {wf, gitHubRepoContext} = args; 18 | 19 | const fileUri = getWorkflowUri(gitHubRepoContext, wf.path); 20 | if (fileUri) { 21 | try { 22 | const textDocument = await vscode.workspace.openTextDocument(fileUri); 23 | await vscode.window.showTextDocument(textDocument); 24 | return; 25 | } catch (e) { 26 | // Ignore error and show error message below 27 | } 28 | } 29 | 30 | // File not found in workspace 31 | await vscode.window.showErrorMessage(`Workflow ${wf.path} not found in current workspace`); 32 | } 33 | ) 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/openWorkflowJobLogs.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../git/repository"; 3 | import {updateDecorations} from "../logs/formatProvider"; 4 | import {getLogInfo} from "../logs/logInfo"; 5 | import {buildLogURI} from "../logs/scheme"; 6 | import {WorkflowJob} from "../store/WorkflowJob"; 7 | 8 | export interface OpenWorkflowJobLogsCommandArgs { 9 | gitHubRepoContext: GitHubRepoContext; 10 | job: WorkflowJob; 11 | } 12 | 13 | export function registerOpenWorkflowJobLogs(context: vscode.ExtensionContext) { 14 | context.subscriptions.push( 15 | vscode.commands.registerCommand("github-actions.workflow.logs", async (args: OpenWorkflowJobLogsCommandArgs) => { 16 | const gitHubRepoContext = args.gitHubRepoContext; 17 | const job = args.job; 18 | const uri = buildLogURI( 19 | `%23${job.job.run_id} - ${job.job.name}`, 20 | gitHubRepoContext.owner, 21 | gitHubRepoContext.name, 22 | job.job.id 23 | ); 24 | 25 | const doc = await vscode.workspace.openTextDocument(uri); 26 | const editor = await vscode.window.showTextDocument(doc, { 27 | preview: false 28 | }); 29 | 30 | const logInfo = getLogInfo(uri); 31 | if (!logInfo) { 32 | throw new Error("Could not get log info"); 33 | } 34 | 35 | // Custom formatting after the editor has been opened 36 | updateDecorations(editor, logInfo); 37 | }) 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/commands/openWorkflowRun.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {WorkflowRunCommandArgs} from "../treeViews/shared/workflowRunNode"; 3 | 4 | export function registerOpenWorkflowRun(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.workflow.run.open", async (args: WorkflowRunCommandArgs) => { 7 | const run = args.run; 8 | const url = run.run.html_url; 9 | await vscode.env.openExternal(vscode.Uri.parse(url)); 10 | }) 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/commands/openWorkflowStepLogs.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {WorkflowStepNode} from "../treeViews/workflows/workflowStepNode"; 3 | 4 | type WorkflowStepCommandArgs = Pick; 5 | 6 | export function registerOpenWorkflowStepLogs(context: vscode.ExtensionContext) { 7 | context.subscriptions.push( 8 | vscode.commands.registerCommand("github-actions.step.logs", async (args: WorkflowStepCommandArgs) => { 9 | const job = args.job.job; 10 | let url = job.html_url ?? ""; 11 | const stepName = args.step.name; 12 | 13 | const index = job.steps && job.steps.findIndex(step => step.name === stepName) + 1; 14 | 15 | if (url && index) { 16 | url = url + "#step:" + index.toString() + ":1"; 17 | } 18 | 19 | await vscode.env.openExternal(vscode.Uri.parse(url)); 20 | }) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/commands/pinWorkflow.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {pinWorkflow} from "../configuration/configuration"; 4 | import {GitHubRepoContext} from "../git/repository"; 5 | import {Workflow} from "../model"; 6 | import {getWorkflowUri} from "../workflow/workflow"; 7 | 8 | interface PinWorkflowCommandOptions { 9 | gitHubRepoContext: GitHubRepoContext; 10 | wf?: Workflow; 11 | 12 | updateContextValue(): void; 13 | } 14 | 15 | export function registerPinWorkflow(context: vscode.ExtensionContext) { 16 | context.subscriptions.push( 17 | vscode.commands.registerCommand("github-actions.workflow.pin", async (args: PinWorkflowCommandOptions) => { 18 | const {gitHubRepoContext, wf} = args; 19 | 20 | if (!wf) { 21 | return; 22 | } 23 | 24 | const workflowFullPath = getWorkflowUri(gitHubRepoContext, wf.path); 25 | if (!workflowFullPath) { 26 | return; 27 | } 28 | 29 | const relativeWorkflowPath = vscode.workspace.asRelativePath(workflowFullPath); 30 | await pinWorkflow(relativeWorkflowPath); 31 | 32 | args.updateContextValue(); 33 | 34 | // Refresh tree to reflect updated `pin/unpin` icon 35 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 36 | }) 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/rerunWorkflowRun.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {WorkflowRunCommandArgs} from "../treeViews/shared/workflowRunNode"; 3 | 4 | export function registerReRunWorkflowRun(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.workflow.run.rerun", async (args: WorkflowRunCommandArgs) => { 7 | const gitHubRepoContext = args.gitHubRepoContext; 8 | const run = args.run; 9 | 10 | try { 11 | await gitHubRepoContext.client.actions.reRunWorkflow({ 12 | owner: gitHubRepoContext.owner, 13 | repo: gitHubRepoContext.name, 14 | run_id: run.run.id 15 | }); 16 | } catch (e) { 17 | await vscode.window.showErrorMessage(`Could not rerun workflow: '${(e as Error).message}'`); 18 | } 19 | 20 | // Start refreshing the run to reflect rerunning in UI 21 | args.store.pollRun(run.run.id, gitHubRepoContext, 1000, 20); 22 | }) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/secrets/addSecret.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {encodeSecret} from "../../secrets"; 4 | import {EnvironmentSecretsCommandArgs} from "../../treeViews/settings/environmentSecretsNode"; 5 | import {RepoSecretsCommandArgs} from "../../treeViews/settings/repoSecretsNode"; 6 | 7 | type AddSecretCommandArgs = RepoSecretsCommandArgs | EnvironmentSecretsCommandArgs; 8 | 9 | export function registerAddSecret(context: vscode.ExtensionContext) { 10 | context.subscriptions.push( 11 | vscode.commands.registerCommand("github-actions.settings.secret.add", async (args: AddSecretCommandArgs) => { 12 | const {gitHubRepoContext} = args; 13 | 14 | const name = await vscode.window.showInputBox({ 15 | prompt: "Enter name for new secret", 16 | ignoreFocusOut: true 17 | }); 18 | 19 | if (!name) { 20 | return; 21 | } 22 | 23 | const value = await vscode.window.showInputBox({ 24 | prompt: "Enter the new secret value", 25 | ignoreFocusOut: true 26 | }); 27 | 28 | if (!value) { 29 | return; 30 | } 31 | 32 | try { 33 | if ("environment" in args) { 34 | await createOrUpdateEnvSecret(gitHubRepoContext, args.environment.name, name, value); 35 | } else { 36 | await createOrUpdateRepoSecret(gitHubRepoContext, name, value); 37 | } 38 | } catch (e) { 39 | await vscode.window.showErrorMessage((e as Error).message); 40 | } 41 | 42 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 43 | }) 44 | ); 45 | } 46 | 47 | export async function createOrUpdateRepoSecret(context: GitHubRepoContext, name: string, value: string) { 48 | const keyResponse = await context.client.actions.getRepoPublicKey({ 49 | owner: context.owner, 50 | repo: context.name 51 | }); 52 | 53 | await context.client.actions.createOrUpdateRepoSecret({ 54 | owner: context.owner, 55 | repo: context.name, 56 | secret_name: name, 57 | key_id: keyResponse.data.key_id, 58 | encrypted_value: await encodeSecret(keyResponse.data.key, value) 59 | }); 60 | } 61 | 62 | export async function createOrUpdateEnvSecret( 63 | context: GitHubRepoContext, 64 | environment: string, 65 | name: string, 66 | value: string 67 | ) { 68 | const keyResponse = await context.client.actions.getEnvironmentPublicKey({ 69 | owner: context.owner, 70 | repo: context.name, 71 | environment_name: environment 72 | }); 73 | 74 | await context.client.actions.createOrUpdateEnvironmentSecret({ 75 | owner: context.owner, 76 | repo: context.name, 77 | environment_name: environment, 78 | secret_name: name, 79 | key_id: keyResponse.data.key_id, 80 | encrypted_value: await encodeSecret(keyResponse.data.key, value) 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/secrets/copySecret.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {SecretCommandArgs} from "../../treeViews/settings/secretNode"; 3 | 4 | export function registerCopySecret(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.settings.secret.copy", async (args: SecretCommandArgs) => { 7 | const {secret} = args; 8 | 9 | await vscode.env.clipboard.writeText(secret.name); 10 | 11 | vscode.window.setStatusBarMessage(`Copied ${secret.name}`, 2000); 12 | }) 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/secrets/deleteSecret.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {SecretCommandArgs} from "../../treeViews/settings/secretNode"; 3 | 4 | export function registerDeleteSecret(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.settings.secret.delete", async (args: SecretCommandArgs) => { 7 | const {gitHubRepoContext, secret, environment} = args; 8 | 9 | const acceptText = "Yes, delete this secret"; 10 | try { 11 | await vscode.window 12 | .showInformationMessage( 13 | `Are you sure you want to delete ${secret.name}?`, 14 | {modal: true, detail: "Deleting this secret cannot be undone and may impact workflows in this repository"}, 15 | acceptText 16 | ) 17 | .then(async answer => { 18 | if (answer === acceptText) { 19 | if (environment) { 20 | await gitHubRepoContext.client.actions.deleteEnvironmentSecret({ 21 | owner: gitHubRepoContext.owner, 22 | repo: gitHubRepoContext.name, 23 | environment_name: environment.name, 24 | secret_name: secret.name 25 | }); 26 | } else { 27 | await gitHubRepoContext.client.actions.deleteRepoSecret({ 28 | owner: gitHubRepoContext.owner, 29 | repo: gitHubRepoContext.name, 30 | secret_name: secret.name 31 | }); 32 | } 33 | } 34 | }); 35 | } catch (e) { 36 | await vscode.window.showErrorMessage((e as Error).message); 37 | } 38 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 39 | }) 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/secrets/updateSecret.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {SecretCommandArgs} from "../../treeViews/settings/secretNode"; 3 | import {createOrUpdateEnvSecret, createOrUpdateRepoSecret} from "./addSecret"; 4 | 5 | export function registerUpdateSecret(context: vscode.ExtensionContext) { 6 | context.subscriptions.push( 7 | vscode.commands.registerCommand("github-actions.settings.secret.update", async (args: SecretCommandArgs) => { 8 | const {gitHubRepoContext, secret, environment} = args; 9 | 10 | const value = await vscode.window.showInputBox({ 11 | prompt: "Enter the new secret value" 12 | }); 13 | 14 | if (!value) { 15 | return; 16 | } 17 | 18 | try { 19 | if (environment) { 20 | await createOrUpdateEnvSecret(gitHubRepoContext, environment.name, secret.name, value); 21 | } else { 22 | await createOrUpdateRepoSecret(gitHubRepoContext, secret.name, value); 23 | } 24 | } catch (e) { 25 | await vscode.window.showErrorMessage((e as Error).message); 26 | } 27 | }) 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/commands/signIn.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {getSession} from "../auth/auth"; 3 | import {canReachGitHubAPI} from "../api/canReachGitHubAPI"; 4 | import {getGitHubContext} from "../git/repository"; 5 | 6 | export function registerSignIn(context: vscode.ExtensionContext) { 7 | context.subscriptions.push( 8 | vscode.commands.registerCommand("github-actions.sign-in", async () => { 9 | const session = await getSession(true); 10 | if (session) { 11 | const canReachAPI = await canReachGitHubAPI(); 12 | const ghContext = await getGitHubContext(); 13 | const hasGitHubRepos = ghContext && ghContext.repos.length > 0; 14 | 15 | await vscode.commands.executeCommand("setContext", "github-actions.signed-in", true); 16 | await vscode.commands.executeCommand("setContext", "github-actions.internet-access", canReachAPI); 17 | await vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos); 18 | } 19 | }) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/commands/unpinWorkflow.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {unpinWorkflow} from "../configuration/configuration"; 4 | import {GitHubRepoContext} from "../git/repository"; 5 | import {Workflow} from "../model"; 6 | import {getWorkflowUri} from "../workflow/workflow"; 7 | 8 | interface UnPinWorkflowCommandOptions { 9 | gitHubRepoContext: GitHubRepoContext; 10 | wf?: Workflow; 11 | 12 | updateContextValue(): void; 13 | } 14 | 15 | export function registerUnPinWorkflow(context: vscode.ExtensionContext) { 16 | context.subscriptions.push( 17 | vscode.commands.registerCommand("github-actions.workflow.unpin", async (args: UnPinWorkflowCommandOptions) => { 18 | const {gitHubRepoContext, wf} = args; 19 | 20 | if (!wf) { 21 | return; 22 | } 23 | 24 | const workflowFullPath = getWorkflowUri(gitHubRepoContext, wf.path); 25 | if (!workflowFullPath) { 26 | return; 27 | } 28 | 29 | const relativeWorkflowPath = vscode.workspace.asRelativePath(workflowFullPath); 30 | await unpinWorkflow(relativeWorkflowPath); 31 | 32 | args.updateContextValue(); 33 | 34 | // Refresh tree to reflect updated `pin/unpin` icon 35 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 36 | }) 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/variables/addVariable.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {EnvironmentVariablesCommandArgs} from "../../treeViews/settings/environmentVariablesNode"; 3 | import {RepoVariablesCommandArgs} from "../../treeViews/settings/repoVariablesNode"; 4 | 5 | type Args = RepoVariablesCommandArgs | EnvironmentVariablesCommandArgs; 6 | 7 | export function registerAddVariable(context: vscode.ExtensionContext) { 8 | context.subscriptions.push( 9 | vscode.commands.registerCommand("github-actions.settings.variable.add", async (args: Args) => { 10 | const {gitHubRepoContext} = args; 11 | 12 | const name = await vscode.window.showInputBox({ 13 | prompt: "Enter name for new variable", 14 | placeHolder: "Variable name", 15 | ignoreFocusOut: true 16 | }); 17 | 18 | if (!name) { 19 | return; 20 | } 21 | 22 | const value = await vscode.window.showInputBox({ 23 | prompt: "Enter the new variable value", 24 | ignoreFocusOut: true 25 | }); 26 | 27 | if (!value) { 28 | return; 29 | } 30 | 31 | try { 32 | if ("environment" in args) { 33 | await gitHubRepoContext.client.actions.createEnvironmentVariable({ 34 | owner: gitHubRepoContext.owner, 35 | repo: gitHubRepoContext.name, 36 | environment_name: args.environment.name, 37 | name, 38 | value 39 | }); 40 | } else { 41 | await gitHubRepoContext.client.actions.createRepoVariable({ 42 | owner: gitHubRepoContext.owner, 43 | repo: gitHubRepoContext.name, 44 | name, 45 | value 46 | }); 47 | } 48 | } catch (e) { 49 | await vscode.window.showErrorMessage((e as Error).message); 50 | } 51 | 52 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 53 | }) 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/variables/copyVariable.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {VariableCommandArgs} from "../../treeViews/settings/variableNode"; 3 | 4 | export function registerCopyVariable(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.settings.variable.copy-name", async (args: VariableCommandArgs) => { 7 | const {variable} = args; 8 | 9 | await vscode.env.clipboard.writeText(variable.name); 10 | 11 | vscode.window.setStatusBarMessage(`Copied ${variable.name}`, 2000); 12 | }), 13 | vscode.commands.registerCommand( 14 | "github-actions.settings.variable.copy-value", 15 | async (args: VariableCommandArgs) => { 16 | const {variable} = args; 17 | 18 | await vscode.env.clipboard.writeText(variable.value); 19 | 20 | vscode.window.setStatusBarMessage(`Copied ${variable.value}`, 2000); 21 | } 22 | ) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/variables/deleteVariable.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {VariableCommandArgs} from "../../treeViews/settings/variableNode"; 3 | 4 | export function registerDeleteVariable(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.settings.variable.delete", async (args: VariableCommandArgs) => { 7 | const {gitHubRepoContext, variable, environment} = args; 8 | const acceptText = "Yes, delete this variable"; 9 | 10 | try { 11 | await vscode.window 12 | .showInformationMessage( 13 | `Are you sure you want to delete ${variable.name}?`, 14 | { 15 | modal: true, 16 | detail: "Deleting this variable cannot be undone and may impact workflows in this repository" 17 | }, 18 | acceptText 19 | ) 20 | .then(async answer => { 21 | if (answer === acceptText) { 22 | if (environment) { 23 | await gitHubRepoContext.client.request( 24 | `DELETE /repositories/${gitHubRepoContext.id}/environments/${environment.name}/variables/${variable.name}` 25 | ); 26 | } else { 27 | await gitHubRepoContext.client.actions.deleteRepoVariable({ 28 | owner: gitHubRepoContext.owner, 29 | repo: gitHubRepoContext.name, 30 | name: variable.name 31 | }); 32 | } 33 | } 34 | }); 35 | } catch (e) { 36 | await vscode.window.showErrorMessage((e as Error).message); 37 | } 38 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 39 | }) 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/variables/updateVariable.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {VariableCommandArgs} from "../../treeViews/settings/variableNode"; 3 | 4 | export function registerUpdateVariable(context: vscode.ExtensionContext) { 5 | context.subscriptions.push( 6 | vscode.commands.registerCommand("github-actions.settings.variable.update", async (args: VariableCommandArgs) => { 7 | const {gitHubRepoContext, variable, environment} = args; 8 | 9 | const name = await vscode.window.showInputBox({ 10 | prompt: "Enter the new variable name", 11 | value: variable.name 12 | }); 13 | 14 | const value = await vscode.window.showInputBox({ 15 | prompt: "Enter the new variable value", 16 | value: variable.value 17 | }); 18 | 19 | if (name == variable.name && value == variable.value) { 20 | return; 21 | } 22 | 23 | const payload: {name?: string; value?: string} = {}; 24 | if (name != variable.name) { 25 | payload.name = name; 26 | } 27 | if (value != variable.value) { 28 | payload.value = value; 29 | } 30 | 31 | try { 32 | if (environment) { 33 | await gitHubRepoContext.client.request( 34 | `PATCH /repositories/${gitHubRepoContext.id}/environments/${environment.name}/variables/${variable.name}`, 35 | payload 36 | ); 37 | } else { 38 | await gitHubRepoContext.client.request( 39 | `PATCH /repos/${gitHubRepoContext.owner}/${gitHubRepoContext.name}/actions/variables/${variable.name}`, 40 | payload 41 | ); 42 | } 43 | } catch (e) { 44 | await vscode.window.showErrorMessage((e as Error).message); 45 | } 46 | 47 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 48 | }) 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/configuration/configuration.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {deactivateLanguageServer, initLanguageServer} from "../workflow/languageServer"; 3 | import {resetGitHubContext} from "../git/repository"; 4 | 5 | const settingsKey = "github-actions"; 6 | const DEFAULT_GITHUB_API = "https://api.github.com"; 7 | 8 | export function initConfiguration(context: vscode.ExtensionContext) { 9 | context.subscriptions.push( 10 | vscode.workspace.onDidChangeConfiguration(async e => { 11 | if (e.affectsConfiguration(getSettingsKey("workflows.pinned"))) { 12 | pinnedWorkflowsChangeHandlers.forEach(h => h()); 13 | } else if ( 14 | e.affectsConfiguration(getSettingsKey("use-enterprise")) || 15 | (useEnterprise() && 16 | (e.affectsConfiguration("github-enterprise.uri") || e.affectsConfiguration(getSettingsKey("remote-name")))) 17 | ) { 18 | await updateLanguageServerApiUrl(context); 19 | resetGitHubContext(); 20 | await vscode.commands.executeCommand("github-actions.explorer.refresh"); 21 | } 22 | }) 23 | ); 24 | } 25 | 26 | function getConfiguration() { 27 | return vscode.workspace.getConfiguration(); 28 | } 29 | 30 | function getSettingsKey(settingsPath: string): string { 31 | return `${settingsKey}.${settingsPath}`; 32 | } 33 | 34 | const pinnedWorkflowsChangeHandlers: (() => void)[] = []; 35 | export function onPinnedWorkflowsChange(handler: () => void) { 36 | pinnedWorkflowsChangeHandlers.push(handler); 37 | } 38 | 39 | export function getPinnedWorkflows(): string[] { 40 | return getConfiguration().get(getSettingsKey("workflows.pinned.workflows"), []); 41 | } 42 | 43 | export async function pinWorkflow(workflow: string) { 44 | const pinedWorkflows = Array.from(new Set(getPinnedWorkflows()).add(workflow)); 45 | await getConfiguration().update(getSettingsKey("workflows.pinned.workflows"), pinedWorkflows); 46 | } 47 | 48 | export async function unpinWorkflow(workflow: string) { 49 | const x = new Set(getPinnedWorkflows()); 50 | x.delete(workflow); 51 | const pinnedWorkflows = Array.from(x); 52 | await getConfiguration().update(getSettingsKey("workflows.pinned.workflows"), pinnedWorkflows); 53 | } 54 | 55 | export function isPinnedWorkflowsRefreshEnabled(): boolean { 56 | return getConfiguration().get(getSettingsKey("workflows.pinned.refresh.enabled"), false); 57 | } 58 | 59 | export function pinnedWorkflowsRefreshInterval(): number { 60 | return getConfiguration().get(getSettingsKey("workflows.pinned.refresh.interval"), 60); 61 | } 62 | 63 | export function getRemoteName(): string { 64 | return getConfiguration().get(getSettingsKey("remote-name"), "origin"); 65 | } 66 | 67 | export function useEnterprise(): boolean { 68 | return getConfiguration().get(getSettingsKey("use-enterprise"), false); 69 | } 70 | 71 | export function getGitHubApiUri(): string { 72 | if (!useEnterprise()) return DEFAULT_GITHUB_API; 73 | const base = getConfiguration().get("github-enterprise.uri", DEFAULT_GITHUB_API).replace(/\/$/, ""); 74 | if (base === DEFAULT_GITHUB_API) { 75 | return base; 76 | } 77 | 78 | if (base.endsWith(".ghe.com")) { 79 | return base.replace(/^(https?):\/\//, "$1://api."); 80 | } else { 81 | return `${base}/api/v3`; 82 | } 83 | } 84 | 85 | async function updateLanguageServerApiUrl(context: vscode.ExtensionContext) { 86 | await deactivateLanguageServer(); 87 | 88 | await initLanguageServer(context); 89 | } 90 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {canReachGitHubAPI} from "./api/canReachGitHubAPI"; 4 | import {getSession} from "./auth/auth"; 5 | import {registerCancelWorkflowRun} from "./commands/cancelWorkflowRun"; 6 | import {registerOpenWorkflowFile} from "./commands/openWorkflowFile"; 7 | import {registerOpenWorkflowJobLogs} from "./commands/openWorkflowJobLogs"; 8 | import {registerOpenWorkflowStepLogs} from "./commands/openWorkflowStepLogs"; 9 | import {registerOpenWorkflowRun} from "./commands/openWorkflowRun"; 10 | import {registerPinWorkflow} from "./commands/pinWorkflow"; 11 | import {registerReRunWorkflowRun} from "./commands/rerunWorkflowRun"; 12 | import {registerAddSecret} from "./commands/secrets/addSecret"; 13 | import {registerCopySecret} from "./commands/secrets/copySecret"; 14 | import {registerDeleteSecret} from "./commands/secrets/deleteSecret"; 15 | import {registerUpdateSecret} from "./commands/secrets/updateSecret"; 16 | import {registerTriggerWorkflowRun} from "./commands/triggerWorkflowRun"; 17 | import {registerUnPinWorkflow} from "./commands/unpinWorkflow"; 18 | import {registerAddVariable} from "./commands/variables/addVariable"; 19 | import {registerCopyVariable} from "./commands/variables/copyVariable"; 20 | import {registerDeleteVariable} from "./commands/variables/deleteVariable"; 21 | import {registerUpdateVariable} from "./commands/variables/updateVariable"; 22 | import {initConfiguration} from "./configuration/configuration"; 23 | import {getGitHubContext} from "./git/repository"; 24 | import {init as initLogger, log, revealLog} from "./log"; 25 | import {LogScheme} from "./logs/constants"; 26 | import {WorkflowStepLogProvider} from "./logs/fileProvider"; 27 | import {WorkflowStepLogFoldingProvider} from "./logs/foldingProvider"; 28 | import {WorkflowStepLogSymbolProvider} from "./logs/symbolProvider"; 29 | import {initPinnedWorkflows} from "./pinnedWorkflows/pinnedWorkflows"; 30 | import {RunStore} from "./store/store"; 31 | import {initWorkflowDocumentTracking} from "./tracker/workflowDocumentTracker"; 32 | import {initWorkspaceChangeTracker} from "./tracker/workspaceTracker"; 33 | import {initResources} from "./treeViews/icons"; 34 | import {initTreeViews} from "./treeViews/treeViews"; 35 | import {deactivateLanguageServer, initLanguageServer} from "./workflow/languageServer"; 36 | import {registerSignIn} from "./commands/signIn"; 37 | 38 | export async function activate(context: vscode.ExtensionContext) { 39 | initLogger(); 40 | 41 | log("Activating GitHub Actions extension..."); 42 | 43 | const hasSession = !!(await getSession()); 44 | const canReachAPI = hasSession && (await canReachGitHubAPI()); 45 | 46 | // Prefetch git repository origin url 47 | const ghContext = hasSession && (await getGitHubContext()); 48 | const hasGitHubRepos = ghContext && ghContext.repos.length > 0; 49 | 50 | await Promise.all([ 51 | vscode.commands.executeCommand("setContext", "github-actions.signed-in", hasSession), 52 | vscode.commands.executeCommand("setContext", "github-actions.internet-access", canReachAPI), 53 | vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos) 54 | ]); 55 | 56 | initResources(context); 57 | initConfiguration(context); 58 | 59 | // Track workflow documents and workspace changes 60 | initWorkspaceChangeTracker(context); 61 | await initWorkflowDocumentTracking(context); 62 | 63 | const store = new RunStore(); 64 | 65 | // Pinned workflows 66 | await initPinnedWorkflows(store); 67 | 68 | // Tree views 69 | await initTreeViews(context, store); 70 | 71 | // Commands 72 | registerOpenWorkflowRun(context); 73 | registerOpenWorkflowFile(context); 74 | registerOpenWorkflowJobLogs(context); 75 | registerOpenWorkflowStepLogs(context); 76 | registerTriggerWorkflowRun(context); 77 | registerReRunWorkflowRun(context); 78 | registerCancelWorkflowRun(context); 79 | 80 | registerAddSecret(context); 81 | registerDeleteSecret(context); 82 | registerCopySecret(context); 83 | registerUpdateSecret(context); 84 | 85 | registerAddVariable(context); 86 | registerUpdateVariable(context); 87 | registerDeleteVariable(context); 88 | registerCopyVariable(context); 89 | 90 | registerPinWorkflow(context); 91 | registerUnPinWorkflow(context); 92 | 93 | registerSignIn(context); 94 | 95 | // Log providers 96 | context.subscriptions.push( 97 | vscode.workspace.registerTextDocumentContentProvider(LogScheme, new WorkflowStepLogProvider()) 98 | ); 99 | 100 | context.subscriptions.push( 101 | vscode.languages.registerFoldingRangeProvider({scheme: LogScheme}, new WorkflowStepLogFoldingProvider()) 102 | ); 103 | 104 | context.subscriptions.push( 105 | vscode.languages.registerDocumentSymbolProvider( 106 | { 107 | scheme: LogScheme 108 | }, 109 | new WorkflowStepLogSymbolProvider() 110 | ) 111 | ); 112 | 113 | // Editing features 114 | await initLanguageServer(context); 115 | 116 | log("...initialized"); 117 | 118 | if (!PRODUCTION) { 119 | // In debugging mode, always open the log for the extension in the `Output` window 120 | revealLog(); 121 | } 122 | } 123 | 124 | export function deactivate(): Thenable | undefined { 125 | return deactivateLanguageServer(); 126 | } 127 | -------------------------------------------------------------------------------- /src/external/README.md: -------------------------------------------------------------------------------- 1 | Code in here is forked from the Microsoft GitHub Pull Request extension 2 | https://github.com/microsoft/vscode-pull-request-github/ 3 | -------------------------------------------------------------------------------- /src/external/ssh.ts: -------------------------------------------------------------------------------- 1 | import {Config, ConfigResolver, parse as parseConfig} from "ssh-config"; 2 | 3 | const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/; 4 | const URL_SCHEME_RE = /^([a-z-]+):\/\//; 5 | 6 | /** 7 | * Parse and resolve an SSH url. Resolves host aliases using the configuration 8 | * specified by ~/.ssh/config, if present. 9 | * 10 | * Examples: 11 | * 12 | * resolve("git@github.com:Microsoft/vscode") 13 | * { 14 | * Host: 'github.com', 15 | * HostName: 'github.com', 16 | * User: 'git', 17 | * path: 'Microsoft/vscode', 18 | * } 19 | * 20 | * resolve("hub:queerviolet/vscode", resolverFromConfig("Host hub\n HostName github.com\n User git\n")) 21 | * { 22 | * Host: 'hub', 23 | * HostName: 'github.com', 24 | * User: 'git', 25 | * path: 'queerviolet/vscode', 26 | * } 27 | * 28 | * @param {string} url the url to parse 29 | * @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config) 30 | * @returns {Config} 31 | */ 32 | export const resolve = (url: string, resolveConfig = Resolvers.current) => { 33 | const config = parse(url); 34 | return config && resolveConfig(config); 35 | }; 36 | 37 | export class Resolvers { 38 | static default = chainResolvers(baseResolver /*, resolverFromConfigFile()*/); 39 | 40 | static fromConfig(conf: string) { 41 | return chainResolvers(baseResolver, resolverFromConfig(conf)); 42 | } 43 | 44 | static current = Resolvers.default; 45 | } 46 | 47 | const parse = (url: string): Config | undefined => { 48 | const urlMatch = URL_SCHEME_RE.exec(url); 49 | if (urlMatch) { 50 | const [fullSchemePrefix, scheme] = urlMatch; 51 | if (scheme === "ssh") { 52 | url = url.slice(fullSchemePrefix.length); 53 | } else { 54 | return; 55 | } 56 | } 57 | const match = SSH_URL_RE.exec(url); 58 | if (!match) { 59 | return; 60 | } 61 | const [, User, Host, path] = match; 62 | return {User, Host, path}; 63 | }; 64 | 65 | function baseResolver(config: Config) { 66 | return { 67 | ...config, 68 | HostName: config.Host 69 | }; 70 | } 71 | 72 | // Temporarily disable this to remove `fs` dependency 73 | // function resolverFromConfigFile( 74 | // configPath = join(homedir(), ".ssh", "config") 75 | // ): ConfigResolver | undefined { 76 | // try { 77 | // const config = readFileSync(configPath).toString(); 78 | // return resolverFromConfig(config); 79 | // } catch (error) { 80 | // // Logger.appendLine(`${configPath}: ${error.message}`); 81 | // } 82 | // } 83 | 84 | export function resolverFromConfig(text: string): ConfigResolver { 85 | // This causes many linter issues, ignore them in whole file for now 86 | const config = parseConfig(text); 87 | return h => config.compute(h.Host); 88 | } 89 | 90 | function chainResolvers(...chain: (ConfigResolver | undefined)[]): ConfigResolver { 91 | const resolvers = chain.filter(x => !!x) as ConfigResolver[]; 92 | return (config: Config) => 93 | resolvers.reduce( 94 | (resolved, next) => ({ 95 | ...resolved, 96 | ...next(resolved) 97 | }), 98 | config 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/git/repository-permissions.ts: -------------------------------------------------------------------------------- 1 | export type RepositoryPermission = "admin" | "write" | "read"; 2 | 3 | // https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository 4 | // This should always be set for authenticated requests 5 | type RepositoryPermissionResponse = { 6 | admin: boolean; 7 | maintain?: boolean; 8 | push: boolean; 9 | triage?: boolean; 10 | pull: boolean; 11 | }; 12 | 13 | export function getRepositoryPermission(permissions: RepositoryPermissionResponse | undefined): RepositoryPermission { 14 | return permissions?.admin ? "admin" : permissions?.push ? "write" : "read"; 15 | } 16 | 17 | export function hasAdminPermission(permission: RepositoryPermission): boolean { 18 | return permission === "admin"; 19 | } 20 | 21 | export function hasWritePermission(permission: RepositoryPermission): boolean { 22 | return permission === "admin" || permission === "write"; 23 | } 24 | -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | declare let PRODUCTION: boolean; 3 | -------------------------------------------------------------------------------- /src/langserver.ts: -------------------------------------------------------------------------------- 1 | // This file is the main entry point for bundling the language server. It has only a side-effect import of the 2 | // @actions/languageserver module, so that we can more easily reference it in the webpack configuration. 3 | import "@actions/languageserver"; 4 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | enum LogLevel { 4 | Debug = 0, 5 | Info = 1 6 | } 7 | 8 | let logger: vscode.OutputChannel; 9 | const level: LogLevel = PRODUCTION ? LogLevel.Info : LogLevel.Debug; 10 | 11 | export function init() { 12 | logger = vscode.window.createOutputChannel("GitHub Actions"); 13 | } 14 | 15 | export function log(...values: unknown[]) { 16 | logger.appendLine(values.join(" ")); 17 | } 18 | 19 | export function logDebug(...values: unknown[]) { 20 | if (level > LogLevel.Debug) { 21 | return; 22 | } 23 | 24 | logger.appendLine(values.join(" ")); 25 | } 26 | 27 | export function logError(e: Error, ...values: unknown[]) { 28 | logger.appendLine(values.join(" ")); 29 | logger.appendLine(e.message); 30 | if (e.stack) { 31 | logger.appendLine(e.stack); 32 | } 33 | } 34 | 35 | export function revealLog() { 36 | logger.show(); 37 | } 38 | -------------------------------------------------------------------------------- /src/logs/constants.ts: -------------------------------------------------------------------------------- 1 | export const LogScheme = "gh-actions"; 2 | -------------------------------------------------------------------------------- /src/logs/fileProvider.ts: -------------------------------------------------------------------------------- 1 | import {OctokitResponse} from "@octokit/types"; 2 | import * as vscode from "vscode"; 3 | import {getGitHubContextForRepo} from "../git/repository"; 4 | import {cacheLogInfo} from "./logInfo"; 5 | import {parseLog} from "./model"; 6 | import {parseUri} from "./scheme"; 7 | 8 | export class WorkflowStepLogProvider implements vscode.TextDocumentContentProvider { 9 | onDidChangeEmitter = new vscode.EventEmitter(); 10 | onDidChange = this.onDidChangeEmitter.event; 11 | 12 | async provideTextDocumentContent(uri: vscode.Uri): Promise { 13 | const {owner, repo, jobId} = parseUri(uri); 14 | 15 | const githubRepoContext = await getGitHubContextForRepo(owner, repo); 16 | if (!githubRepoContext) { 17 | throw new Error("Could not load logs"); 18 | } 19 | 20 | try { 21 | const result = await githubRepoContext?.client.actions.downloadJobLogsForWorkflowRun({ 22 | owner: owner, 23 | repo: repo, 24 | job_id: jobId 25 | }); 26 | 27 | const log = result.data; 28 | 29 | const logInfo = parseLog(log as string); 30 | cacheLogInfo(uri, logInfo); 31 | 32 | return logInfo.updatedLogLines.join("\n"); 33 | } catch (e) { 34 | const respErr = e as OctokitResponse; 35 | if (respErr.status === 410) { 36 | cacheLogInfo(uri, { 37 | sections: [], 38 | updatedLogLines: [], 39 | styleFormats: [] 40 | }); 41 | 42 | return "Could not open logs, they are expired."; 43 | } 44 | 45 | console.error("Error loading logs", e); 46 | return `Could not open logs, unhandled error. ${(e as Error).message}`; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/logs/foldingProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {getLogInfo} from "./logInfo"; 3 | 4 | export class WorkflowStepLogFoldingProvider implements vscode.FoldingRangeProvider { 5 | provideFoldingRanges(document: vscode.TextDocument): vscode.ProviderResult { 6 | const logInfo = getLogInfo(document.uri); 7 | if (!logInfo) { 8 | return []; 9 | } 10 | 11 | return logInfo.sections.map(s => new vscode.FoldingRange(s.start, s.end, vscode.FoldingRangeKind.Region)); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/logs/formatProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {LogInfo} from "./model"; 3 | import {Parser, VSCodeDefaultColors} from "./parser"; 4 | 5 | const timestampRE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{7}Z/; 6 | 7 | const timestampDecorationType = vscode.window.createTextEditorDecorationType({ 8 | color: "#99999959" 9 | }); 10 | 11 | export function updateDecorations(activeEditor: vscode.TextEditor, logInfo: LogInfo) { 12 | if (!activeEditor) { 13 | return; 14 | } 15 | 16 | // Decorate timestamps 17 | const numberOfLines = activeEditor.document.lineCount; 18 | activeEditor.setDecorations( 19 | timestampDecorationType, 20 | Array.from(Array(numberOfLines).keys()) 21 | .filter(i => { 22 | const line = activeEditor.document.lineAt(i).text; 23 | return timestampRE.test(line); 24 | }) 25 | .map(i => ({ 26 | range: new vscode.Range(i, 0, i, 28) // timestamps always have 28 chars 27 | })) 28 | ); 29 | 30 | // Custom decorations 31 | const decoratorTypes: { 32 | [key: string]: {type: vscode.TextEditorDecorationType; ranges: vscode.Range[]}; 33 | } = {}; 34 | 35 | for (let lineNo = 0; lineNo < logInfo.updatedLogLines.length; lineNo++) { 36 | // .filter() preserves the order of the array 37 | const lineStyles = logInfo.styleFormats.filter(style => style.line == lineNo); 38 | let pos = 0; 39 | for (let styleNo = 0; styleNo < lineStyles.length; styleNo++) { 40 | const styleInfo = lineStyles[styleNo]; 41 | const endPos = pos + styleInfo.content.length; 42 | const range = new vscode.Range(lineNo, pos, lineNo, endPos); 43 | pos = endPos; 44 | 45 | if (styleInfo.style) { 46 | const key = Parser.styleKey(styleInfo.style); 47 | let fgHex = ""; 48 | let bgHex = ""; 49 | 50 | // Convert to hex colors if RGB-formatted, or use lookup for predefined colors 51 | if (styleInfo.style.isFgRGB) { 52 | const rgbValues = styleInfo.style.fg.split(","); 53 | fgHex = rgbToHex(rgbValues); 54 | } else { 55 | fgHex = VSCodeDefaultColors[styleInfo.style.fg] ?? ""; 56 | } 57 | if (styleInfo.style.isBgRGB) { 58 | const rgbValues = styleInfo.style.bg.split(","); 59 | bgHex = rgbToHex(rgbValues); 60 | } else { 61 | bgHex = VSCodeDefaultColors[styleInfo.style.bg] ?? ""; 62 | } 63 | 64 | if (!decoratorTypes[key]) { 65 | decoratorTypes[key] = { 66 | type: vscode.window.createTextEditorDecorationType({ 67 | color: fgHex, 68 | backgroundColor: bgHex, 69 | fontWeight: styleInfo.style.bold ? "bold" : "normal", 70 | fontStyle: styleInfo.style.italic ? "italic" : "normal", 71 | textDecoration: styleInfo.style.underline ? "underline" : "" 72 | }), 73 | ranges: [range] 74 | }; 75 | } else { 76 | decoratorTypes[key].ranges.push(range); 77 | } 78 | } 79 | } 80 | } 81 | 82 | for (const decoratorType of Object.values(decoratorTypes)) { 83 | activeEditor.setDecorations(decoratorType.type, decoratorType.ranges); 84 | } 85 | } 86 | 87 | function rgbToHex(rgbValues: string[]) { 88 | let hex = ""; 89 | if (rgbValues.length == 3) { 90 | hex = "#"; 91 | for (let i = 0; i < 3; i++) { 92 | hex = hex.concat(parseInt(rgbValues[i]).toString(16).padStart(2, "0")); 93 | } 94 | } 95 | return hex; 96 | } 97 | -------------------------------------------------------------------------------- /src/logs/logInfo.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {LogInfo} from "./model"; 3 | 4 | const cache = new Map(); 5 | 6 | export function cacheLogInfo(uri: vscode.Uri, logInfo: LogInfo) { 7 | cache.set(uri.toString(), logInfo); 8 | } 9 | 10 | export function getLogInfo(uri: vscode.Uri): LogInfo | undefined { 11 | return cache.get(uri.toString()); 12 | } 13 | -------------------------------------------------------------------------------- /src/logs/model.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-control-regex 2 | const ansiColorRegex = /\u001b\[(\d+;)*\d+m/gm; 3 | const groupMarker = "##[group]"; 4 | 5 | import {Parser, IStyle} from "./parser"; 6 | 7 | export interface LogSection { 8 | start: number; 9 | end: number; 10 | name?: string; 11 | } 12 | 13 | export interface LogStyleInfo { 14 | line: number; 15 | content: string; 16 | style?: IStyle; 17 | } 18 | 19 | export interface LogInfo { 20 | updatedLogLines: string[]; 21 | sections: LogSection[]; 22 | styleFormats: LogStyleInfo[]; 23 | } 24 | 25 | export function parseLog(log: string): LogInfo { 26 | let firstSection: LogSection | null = { 27 | name: "Setup", 28 | start: 0, 29 | end: 1 30 | }; 31 | 32 | // Assume there is always the setup section 33 | const sections: LogSection[] = [firstSection]; 34 | 35 | let currentRange: LogSection | null = null; 36 | 37 | const parser = new Parser(); 38 | const styleInfo: LogStyleInfo[] = []; 39 | const lines = log.split(/\n|\r/).filter(l => !!l); 40 | 41 | let lineIdx = 0; 42 | 43 | for (const line of lines) { 44 | // Groups 45 | const groupMarkerStart = line.indexOf(groupMarker); 46 | if (groupMarkerStart !== -1) { 47 | // If this is the first group marker we encounter, the previous range was the job setup 48 | if (firstSection) { 49 | firstSection.end = lineIdx - 1; 50 | firstSection = null; 51 | } 52 | 53 | if (currentRange) { 54 | currentRange.end = lineIdx - 1; 55 | sections.push(currentRange); 56 | } 57 | 58 | const name = line.substring(groupMarkerStart + groupMarker.length); 59 | 60 | currentRange = { 61 | name, 62 | start: lineIdx, 63 | end: lineIdx + 1 64 | }; 65 | } 66 | 67 | const stateFragments = parser.getStates(line); 68 | for (const state of stateFragments) { 69 | styleInfo.push({ 70 | line: lineIdx, 71 | content: state.output, 72 | style: state.style 73 | }); 74 | } 75 | 76 | // Remove all other commands and codes from the output, we don't support those 77 | lines[lineIdx] = line.replace(ansiColorRegex, ""); 78 | 79 | ++lineIdx; 80 | } 81 | 82 | if (currentRange) { 83 | currentRange.end = lineIdx - 1; 84 | sections.push(currentRange); 85 | } 86 | 87 | return { 88 | updatedLogLines: lines, 89 | sections: sections, 90 | styleFormats: styleInfo 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/logs/scheme.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {LogScheme} from "./constants"; 3 | 4 | /** 5 | * @param displayName Must not contain '/' 6 | */ 7 | export function buildLogURI(displayName: string, owner: string, repo: string, jobId: number): vscode.Uri { 8 | return vscode.Uri.parse(`${LogScheme}://${owner}/${repo}/${displayName}?${jobId}`); 9 | } 10 | 11 | export function parseUri(uri: vscode.Uri): { 12 | owner: string; 13 | repo: string; 14 | jobId: number; 15 | } { 16 | if (uri.scheme != LogScheme) { 17 | throw new Error("Uri is not of log scheme"); 18 | } 19 | 20 | return { 21 | owner: uri.authority, 22 | repo: uri.path.split("/").slice(0, 2).join(""), 23 | jobId: parseInt(uri.query, 10) 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/logs/symbolProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {getLogInfo} from "./logInfo"; 3 | 4 | export class WorkflowStepLogSymbolProvider implements vscode.DocumentSymbolProvider { 5 | provideDocumentSymbols( 6 | document: vscode.TextDocument 7 | ): vscode.ProviderResult { 8 | const logInfo = getLogInfo(document.uri); 9 | if (!logInfo) { 10 | return []; 11 | } 12 | 13 | return logInfo.sections.map( 14 | s => 15 | new vscode.DocumentSymbol( 16 | s.name || "Setup", 17 | "Step", 18 | vscode.SymbolKind.Function, 19 | new vscode.Range(s.start, 0, s.end, 0), 20 | new vscode.Range(s.start, 0, s.end, 0) 21 | ) 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import {components} from "@octokit/openapi-types"; 2 | import {RestEndpointMethods} from "@octokit/plugin-rest-endpoint-methods/dist-types/generated/method-types"; 3 | 4 | // Type helpers 5 | type Await = T extends { 6 | then(onfulfilled?: (value: infer U) => unknown): unknown; 7 | } 8 | ? U 9 | : T; 10 | 11 | type GetElementType = T extends (infer U)[] ? U : never; 12 | 13 | type OctokitData< 14 | Operation extends keyof RestEndpointMethods["actions"], 15 | ResultProperty extends keyof Await>["data"] 16 | > = GetElementType>["data"][ResultProperty]>; 17 | 18 | type OctokitRepoData< 19 | Operation extends keyof RestEndpointMethods["repos"], 20 | ResultProperty extends keyof Await>["data"] 21 | > = GetElementType>["data"][ResultProperty]>; 22 | 23 | // 24 | // Domain contracts 25 | // 26 | 27 | export type Workflow = OctokitData<"listRepoWorkflows", "workflows">; 28 | export type WorkflowRun = components["schemas"]["workflow-run"]; 29 | export type WorkflowRunAttempt = WorkflowRun; 30 | 31 | export type WorkflowJob = OctokitData<"listJobsForWorkflowRun", "jobs">; 32 | 33 | export type WorkflowStep = GetElementType; 34 | 35 | export type RepoSecret = OctokitData<"listRepoSecrets", "secrets">; 36 | 37 | export type RepoVariable = OctokitData<"listRepoVariables", "variables">; 38 | 39 | export type Environment = OctokitRepoData<"getAllEnvironments", "environments">; 40 | 41 | export type EnvironmentSecret = OctokitData<"listEnvironmentSecrets", "secrets">; 42 | 43 | export type EnvironmentVariable = OctokitData<"listEnvironmentVariables", "variables">; 44 | 45 | export type OrgSecret = {name: string}; 46 | 47 | export type OrgVariable = {name: string; value: string}; 48 | -------------------------------------------------------------------------------- /src/secrets/index.test.ts: -------------------------------------------------------------------------------- 1 | import libsodium from "libsodium-wrappers"; 2 | import {encodeSecret} from "./index"; 3 | 4 | describe("secret encryption", () => { 5 | it("encrypts secret correctly", async () => { 6 | await libsodium.ready; 7 | 8 | // The keys were generated for this test using libsodium.crypto_box_keypair() 9 | const publicKey = "M2Kq4k1y9DiqlqLfm2YYm75x5M3SuwuNYbLyiHEMUAM="; 10 | const privateKey = "RI2kKSjSOBmcjme5x8iv42Ozdu1rDo9QkaU2l+IFcrE="; 11 | 12 | const a = await encodeSecret(publicKey, "secret-value"); 13 | 14 | const da = libsodium.crypto_box_seal_open( 15 | libsodium.from_base64(a, libsodium.base64_variants.ORIGINAL), 16 | libsodium.from_base64(publicKey, libsodium.base64_variants.ORIGINAL), 17 | libsodium.from_base64(privateKey, libsodium.base64_variants.ORIGINAL) 18 | ); 19 | expect(libsodium.to_string(da)).toBe("secret-value"); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/secrets/index.ts: -------------------------------------------------------------------------------- 1 | import libsodium from "libsodium-wrappers"; 2 | 3 | export async function encodeSecret(key: string, value: string): Promise { 4 | await libsodium.ready; 5 | const sec = libsodium.from_string(value); 6 | const k = libsodium.from_base64(key, libsodium.base64_variants.ORIGINAL); 7 | const encsec = libsodium.crypto_box_seal(sec, k); 8 | return libsodium.to_base64(encsec, libsodium.base64_variants.ORIGINAL); 9 | } 10 | -------------------------------------------------------------------------------- /src/store/WorkflowJob.ts: -------------------------------------------------------------------------------- 1 | import {GitHubRepoContext} from "../git/repository"; 2 | import * as model from "../model"; 3 | 4 | export class WorkflowJob { 5 | readonly job: model.WorkflowJob; 6 | private gitHubRepoContext: GitHubRepoContext; 7 | 8 | constructor(gitHubRepoContext: GitHubRepoContext, job: model.WorkflowJob) { 9 | this.gitHubRepoContext = gitHubRepoContext; 10 | this.job = job; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import {setInterval} from "timers"; 2 | import {EventEmitter} from "vscode"; 3 | import {GitHubRepoContext} from "../git/repository"; 4 | import {logDebug} from "../log"; 5 | import * as model from "../model"; 6 | import {WorkflowRun} from "./workflowRun"; 7 | 8 | export interface RunStoreEvent { 9 | run: WorkflowRun; 10 | } 11 | 12 | type Updater = { 13 | intervalMs: number; 14 | remainingAttempts: number; 15 | repoContext: GitHubRepoContext; 16 | runId: number; 17 | handle: NodeJS.Timeout | undefined; 18 | }; 19 | 20 | export class RunStore extends EventEmitter { 21 | private runs = new Map(); 22 | private updaters = new Map(); 23 | 24 | getRun(runId: number): WorkflowRun | undefined { 25 | return this.runs.get(runId); 26 | } 27 | 28 | addRun(gitHubRepoContext: GitHubRepoContext, runData: model.WorkflowRun): WorkflowRun { 29 | let run = this.runs.get(runData.id); 30 | if (!run) { 31 | run = new WorkflowRun(gitHubRepoContext, runData); 32 | 33 | logDebug("[Store]: adding run: ", runData.id, runData.updated_at); 34 | } else { 35 | run.updateRun(runData); 36 | 37 | logDebug("[Store]: updating run: ", runData.id, runData.updated_at); 38 | } 39 | 40 | this.runs.set(runData.id, run); 41 | this.fire({run}); 42 | return run; 43 | } 44 | 45 | /** 46 | * Start polling for updates for the given run 47 | */ 48 | pollRun(runId: number, repoContext: GitHubRepoContext, intervalMs: number, attempts = 10) { 49 | const existingUpdater: Updater | undefined = this.updaters.get(runId); 50 | if (existingUpdater && existingUpdater.handle) { 51 | clearInterval(existingUpdater.handle); 52 | } 53 | 54 | const updater: Updater = { 55 | intervalMs, 56 | repoContext, 57 | runId, 58 | remainingAttempts: attempts, 59 | handle: undefined 60 | }; 61 | 62 | updater.handle = setInterval(() => void this.fetchRun(updater), intervalMs); 63 | 64 | this.updaters.set(runId, updater); 65 | } 66 | 67 | private async fetchRun(updater: Updater) { 68 | logDebug("Updating run: ", updater.runId); 69 | 70 | updater.remainingAttempts--; 71 | if (updater.remainingAttempts === 0) { 72 | if (updater.handle) { 73 | clearInterval(updater.handle); 74 | } 75 | 76 | this.updaters.delete(updater.runId); 77 | } 78 | 79 | const result = await updater.repoContext.client.actions.getWorkflowRun({ 80 | owner: updater.repoContext.owner, 81 | repo: updater.repoContext.name, 82 | run_id: updater.runId 83 | }); 84 | 85 | const run = result.data; 86 | this.addRun(updater.repoContext, run); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/tracker/workflowDocumentTracker.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {extname} from "path"; 4 | import {LogScheme} from "../logs/constants"; 5 | import {updateDecorations} from "../logs/formatProvider"; 6 | import {getLogInfo} from "../logs/logInfo"; 7 | import {getContextStringForWorkflow} from "../workflow/workflow"; 8 | 9 | export async function initWorkflowDocumentTracking(context: vscode.ExtensionContext) { 10 | context.subscriptions.push(vscode.window.onDidChangeActiveTextEditor(onDidChangeActiveTextEditor)); 11 | 12 | // Check for initial document 13 | await onDidChangeActiveTextEditor(vscode.window.activeTextEditor); 14 | } 15 | 16 | async function onDidChangeActiveTextEditor(editor?: vscode.TextEditor) { 17 | if (!editor || !isTextEditor(editor)) { 18 | return; 19 | } 20 | 21 | // Check if the file is saved and could be a workflow 22 | if ( 23 | editor.document.uri?.fsPath && 24 | editor.document.uri.scheme === "file" && 25 | extname(editor.document.fileName).match(/\.ya?ml/) && 26 | editor.document.fileName.indexOf(".github/workflows") !== -1 27 | ) { 28 | await vscode.commands.executeCommand( 29 | "setContext", 30 | "githubActions:activeFile", 31 | await getContextStringForWorkflow(editor.document.uri) 32 | ); 33 | } 34 | 35 | // Is is a log file? 36 | if (editor.document.uri?.scheme === LogScheme) { 37 | const logInfo = getLogInfo(editor.document.uri); 38 | if (logInfo) { 39 | updateDecorations(editor, logInfo); 40 | } 41 | } 42 | } 43 | 44 | // Adapted from https://github.com/eamodio/vscode-gitlens/blob/f22a9cd4199ac498c217643282a6a412e1fc01ae/src/constants.ts#L74 45 | enum DocumentSchemes { 46 | DebugConsole = "debug", 47 | Output = "output" 48 | } 49 | 50 | function isTextEditor(editor: vscode.TextEditor): boolean { 51 | const scheme = editor.document.uri.scheme; 52 | return scheme !== DocumentSchemes.Output && scheme !== DocumentSchemes.DebugConsole; 53 | } 54 | -------------------------------------------------------------------------------- /src/tracker/workspaceTracker.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {getGitHubContext, resetGitHubContext} from "../git/repository"; 4 | 5 | export function initWorkspaceChangeTracker(context: vscode.ExtensionContext) { 6 | const onDidChangeWorkspaceFolders = async (event: vscode.WorkspaceFoldersChangeEvent) => { 7 | if (event.added.length > 0 || event.removed.length > 0) { 8 | resetGitHubContext(); 9 | const context = await getGitHubContext(); 10 | const hasGitHubRepos = context && context.repos.length > 0; 11 | await vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos); 12 | } 13 | }; 14 | context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(onDidChangeWorkspaceFolders)); 15 | } 16 | -------------------------------------------------------------------------------- /src/treeViews/current-branch/currentBranchRepoNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {GitHubRepoContext} from "../../git/repository"; 4 | 5 | export class CurrentBranchRepoNode extends vscode.TreeItem { 6 | constructor(public readonly gitHubRepoContext: GitHubRepoContext, public readonly currentBranchName: string) { 7 | super(gitHubRepoContext.name, vscode.TreeItemCollapsibleState.Collapsed); 8 | 9 | this.description = currentBranchName; 10 | this.contextValue = "cb-repo"; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/treeViews/current-branch/noRunForBranchNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export class NoRunForBranchNode extends vscode.TreeItem { 4 | constructor() { 5 | super("No runs for current branch"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/treeViews/currentBranch.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {canReachGitHubAPI} from "../api/canReachGitHubAPI"; 4 | import {getCurrentBranch, getGitHubContext, GitHubRepoContext} from "../git/repository"; 5 | import {CurrentBranchRepoNode} from "./current-branch/currentBranchRepoNode"; 6 | 7 | import {NoRunForBranchNode} from "./current-branch/noRunForBranchNode"; 8 | import {log, logDebug} from "../log"; 9 | import {RunStore} from "../store/store"; 10 | import {AttemptNode} from "./shared/attemptNode"; 11 | import {GitHubAPIUnreachableNode} from "./shared/gitHubApiUnreachableNode"; 12 | import {NoWorkflowJobsNode} from "./shared/noWorkflowJobsNode"; 13 | import {PreviousAttemptsNode} from "./shared/previousAttemptsNode"; 14 | import {WorkflowJobNode} from "./shared/workflowJobNode"; 15 | import {WorkflowRunNode} from "./shared/workflowRunNode"; 16 | import {WorkflowRunTreeDataProvider} from "./workflowRunTreeDataProvider"; 17 | import {WorkflowStepNode} from "./workflows/workflowStepNode"; 18 | 19 | type CurrentBranchTreeNode = 20 | | CurrentBranchRepoNode 21 | | WorkflowRunNode 22 | | PreviousAttemptsNode 23 | | AttemptNode 24 | | WorkflowJobNode 25 | | NoWorkflowJobsNode 26 | | WorkflowStepNode 27 | | NoRunForBranchNode 28 | | GitHubAPIUnreachableNode; 29 | 30 | export class CurrentBranchTreeProvider 31 | extends WorkflowRunTreeDataProvider 32 | implements vscode.TreeDataProvider 33 | { 34 | protected _onDidChangeTreeData = new vscode.EventEmitter(); 35 | readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 36 | 37 | constructor(store: RunStore) { 38 | super(store); 39 | } 40 | 41 | protected _updateNode(node: WorkflowRunNode): void { 42 | this._onDidChangeTreeData.fire(node); 43 | } 44 | 45 | async refresh(): Promise { 46 | // Don't delete all the nodes if we can't reach GitHub API 47 | if (await canReachGitHubAPI()) { 48 | this._onDidChangeTreeData.fire(null); 49 | } else { 50 | await vscode.window.showWarningMessage("Unable to refresh, could not reach GitHub API"); 51 | } 52 | } 53 | 54 | getTreeItem(element: CurrentBranchTreeNode): vscode.TreeItem | Thenable { 55 | return element; 56 | } 57 | 58 | async getChildren(element?: CurrentBranchTreeNode | undefined): Promise { 59 | if (!element) { 60 | const gitHubContext = await getGitHubContext(); 61 | if (!gitHubContext) { 62 | return [new GitHubAPIUnreachableNode()]; 63 | } 64 | 65 | if (gitHubContext.repos.length === 1) { 66 | const repoContext = gitHubContext.repos[0]; 67 | const currentBranch = getCurrentBranch(repoContext.repositoryState); 68 | if (!currentBranch) { 69 | log(`Could not find current branch for ${repoContext.name}`); 70 | return []; 71 | } 72 | 73 | return (await this.getRuns(repoContext, currentBranch)) || []; 74 | } 75 | 76 | if (gitHubContext.repos.length > 1) { 77 | return gitHubContext.repos 78 | .map((repoContext): CurrentBranchRepoNode | undefined => { 79 | const currentBranch = getCurrentBranch(repoContext.repositoryState); 80 | if (!currentBranch) { 81 | log(`Could not find current branch for ${repoContext.name}`); 82 | return undefined; 83 | } 84 | 85 | return new CurrentBranchRepoNode(repoContext, currentBranch); 86 | }) 87 | .filter(x => x !== undefined) as CurrentBranchRepoNode[]; 88 | } 89 | } else if (element instanceof CurrentBranchRepoNode) { 90 | return this.getRuns(element.gitHubRepoContext, element.currentBranchName); 91 | } else if (element instanceof WorkflowRunNode) { 92 | return element.getJobs(); 93 | } else if (element instanceof PreviousAttemptsNode) { 94 | return element.getAttempts(); 95 | } else if (element instanceof AttemptNode) { 96 | return element.getJobs(); 97 | } else if (element instanceof WorkflowJobNode) { 98 | return element.getSteps(); 99 | } 100 | 101 | return []; 102 | } 103 | 104 | private async getRuns(gitHubRepoContext: GitHubRepoContext, currentBranchName: string): Promise { 105 | logDebug("Getting workflow runs for branch"); 106 | 107 | const result = await gitHubRepoContext.client.actions.listWorkflowRunsForRepo({ 108 | owner: gitHubRepoContext.owner, 109 | repo: gitHubRepoContext.name, 110 | branch: currentBranchName, 111 | per_page: 100 112 | }); 113 | 114 | const resp = result.data; 115 | const runs = resp.workflow_runs; 116 | // We are removing newlines from workflow names for presentation purposes 117 | for (const run of runs) { 118 | run.name = run.name?.replace(/(\r\n|\n|\r)/gm, " "); 119 | } 120 | 121 | return this.runNodes(gitHubRepoContext, runs, true); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/treeViews/icons.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | let _context: vscode.ExtensionContext; 4 | export function initResources(context: vscode.ExtensionContext) { 5 | _context = context; 6 | } 7 | 8 | export interface StatusAndConclusion { 9 | status: string | null; 10 | conclusion: string | null; 11 | } 12 | 13 | export function getAbsoluteIconPath(relativeIconPath: string): { 14 | light: string | vscode.Uri; 15 | dark: string | vscode.Uri; 16 | } { 17 | return { 18 | light: vscode.Uri.joinPath(_context.extensionUri, "resources", "icons", "light", relativeIconPath), 19 | dark: vscode.Uri.joinPath(_context.extensionUri, "resources", "icons", "dark", relativeIconPath) 20 | }; 21 | } 22 | 23 | export function getIconForWorkflowRun({ 24 | status, 25 | conclusion 26 | }: StatusAndConclusion): string | vscode.ThemeIcon | {light: string | vscode.Uri; dark: string | vscode.Uri} { 27 | switch (status) { 28 | case "completed": { 29 | switch (conclusion) { 30 | case "success": 31 | return getAbsoluteIconPath("workflowruns/wr_success.svg"); 32 | 33 | case "startup_failure": 34 | case "failure": 35 | return getAbsoluteIconPath("workflowruns/wr_failure.svg"); 36 | 37 | case "skipped": 38 | return getAbsoluteIconPath("workflowruns/wr_skipped.svg"); 39 | 40 | case "cancelled": 41 | return getAbsoluteIconPath("workflowruns/wr_cancelled.svg"); 42 | } 43 | 44 | break; 45 | } 46 | 47 | case "pending": 48 | return getAbsoluteIconPath("workflowruns/wr_pending.svg"); 49 | 50 | case "requested": 51 | case "queued": 52 | return getAbsoluteIconPath("workflowruns/wr_queued.svg"); 53 | 54 | case "waiting": 55 | return getAbsoluteIconPath("workflowruns/wr_waiting.svg"); 56 | 57 | case "inprogress": 58 | case "in_progress": 59 | return getAbsoluteIconPath("workflowruns/wr_inprogress.svg"); 60 | } 61 | 62 | return ""; 63 | } 64 | 65 | export function getIconForWorkflowStep({ 66 | status, 67 | conclusion 68 | }: StatusAndConclusion): string | vscode.ThemeIcon | {light: string | vscode.Uri; dark: string | vscode.Uri} { 69 | switch (status) { 70 | case "completed": { 71 | switch (conclusion) { 72 | case "success": 73 | return getAbsoluteIconPath("steps/step_success.svg"); 74 | 75 | case "failure": 76 | return getAbsoluteIconPath("steps/step_failure.svg"); 77 | 78 | case "skipped": 79 | return getAbsoluteIconPath("steps/step_skipped.svg"); 80 | 81 | case "cancelled": 82 | return getAbsoluteIconPath("steps/step_cancelled.svg"); 83 | } 84 | 85 | break; 86 | } 87 | 88 | case "queued": 89 | return getAbsoluteIconPath("steps/step_queued.svg"); 90 | 91 | case "inprogress": 92 | case "in_progress": 93 | return getAbsoluteIconPath("steps/step_inprogress.svg"); 94 | } 95 | 96 | return ""; 97 | } 98 | 99 | /** Get one of the built-in VS Code icons */ 100 | export function getCodIconForWorkflowRun(runOrJob?: StatusAndConclusion): string { 101 | if (!runOrJob) { 102 | return "circle-outline"; 103 | } 104 | 105 | switch (runOrJob.status) { 106 | case "completed": { 107 | switch (runOrJob.conclusion) { 108 | case "success": 109 | return "pass"; 110 | 111 | case "failure": 112 | return "error"; 113 | 114 | case "skipped": 115 | case "cancelled": 116 | return "circle-slash"; 117 | } 118 | break; 119 | } 120 | 121 | case "queued": 122 | return "primitive-dot"; 123 | 124 | case "waiting": 125 | return "bell"; 126 | 127 | case "inprogress": 128 | case "in_progress": 129 | return "sync~spin"; 130 | } 131 | 132 | // Default to circle if there is no match 133 | return "circle"; 134 | } 135 | -------------------------------------------------------------------------------- /src/treeViews/settings.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {canReachGitHubAPI} from "../api/canReachGitHubAPI"; 4 | import {SettingsRepoNode, getSettingNodes} from "./settings/settingsRepoNode"; 5 | import {EnvironmentNode} from "./settings/environmentNode"; 6 | import {EnvironmentsNode} from "./settings/environmentsNode"; 7 | import {RepoSecretsNode} from "./settings/repoSecretsNode"; 8 | import {SecretsNode} from "./settings/secretsNode"; 9 | import {SettingsExplorerNode} from "./settings/types"; 10 | import {getGitHubContext} from "../git/repository"; 11 | import {RepoVariablesNode} from "./settings/repoVariablesNode"; 12 | import {VariablesNode} from "./settings/variablesNode"; 13 | import {EnvironmentSecretsNode} from "./settings/environmentSecretsNode"; 14 | import {EnvironmentVariablesNode} from "./settings/environmentVariablesNode"; 15 | import {OrgVariablesNode} from "./settings/orgVariablesNode"; 16 | import {OrgSecretsNode} from "./settings/orgSecretsNode"; 17 | import {GitHubAPIUnreachableNode} from "./shared/gitHubApiUnreachableNode"; 18 | 19 | export class SettingsTreeProvider implements vscode.TreeDataProvider { 20 | private _onDidChangeTreeData = new vscode.EventEmitter(); 21 | readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 22 | 23 | async refresh(): Promise { 24 | // Don't delete all the nodes if we can't reach GitHub API 25 | if (await canReachGitHubAPI()) { 26 | this._onDidChangeTreeData.fire(null); 27 | } else { 28 | await vscode.window.showWarningMessage("Unable to refresh, could not reach GitHub API"); 29 | } 30 | } 31 | 32 | getTreeItem(element: SettingsExplorerNode): vscode.TreeItem | Thenable { 33 | return element; 34 | } 35 | 36 | async getChildren(element?: SettingsExplorerNode | undefined): Promise { 37 | const gitHubContext = await getGitHubContext(); 38 | if (!gitHubContext) { 39 | return [new GitHubAPIUnreachableNode()]; 40 | } 41 | 42 | if (!element) { 43 | if (gitHubContext.repos.length > 0) { 44 | if (gitHubContext.repos.length == 1) { 45 | return getSettingNodes(gitHubContext.repos[0]); 46 | } 47 | 48 | return gitHubContext.repos.map(r => new SettingsRepoNode(r)); 49 | } 50 | } 51 | 52 | if (element instanceof SettingsRepoNode) { 53 | return element.getSettings(); 54 | } 55 | 56 | // 57 | // Secrets 58 | // 59 | if (element instanceof SecretsNode) { 60 | return element.nodes; 61 | } 62 | 63 | if (element instanceof RepoSecretsNode || element instanceof OrgSecretsNode) { 64 | return element.getSecrets(); 65 | } 66 | 67 | // 68 | // Variables 69 | // 70 | if (element instanceof VariablesNode) { 71 | return element.nodes; 72 | } 73 | 74 | if (element instanceof RepoVariablesNode || element instanceof OrgVariablesNode) { 75 | return element.getVariables(); 76 | } 77 | 78 | // 79 | // Environments 80 | // 81 | 82 | if (element instanceof EnvironmentsNode) { 83 | return element.getEnvironments(); 84 | } 85 | 86 | if (element instanceof EnvironmentNode) { 87 | return element.getNodes(); 88 | } 89 | 90 | if (element instanceof EnvironmentSecretsNode) { 91 | return element.getSecrets(); 92 | } 93 | 94 | if (element instanceof EnvironmentVariablesNode) { 95 | return element.getVariables(); 96 | } 97 | 98 | return []; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/treeViews/settings/emptyNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export class EmptyNode extends vscode.TreeItem { 4 | constructor(message: string) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/treeViews/settings/environmentNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {hasWritePermission} from "../../git/repository-permissions"; 4 | import {Environment} from "../../model"; 5 | import {EnvironmentSecretsNode} from "./environmentSecretsNode"; 6 | import {EnvironmentVariablesNode} from "./environmentVariablesNode"; 7 | import {SettingsExplorerNode} from "./types"; 8 | 9 | export class EnvironmentNode extends vscode.TreeItem { 10 | constructor(public readonly gitHubRepoContext: GitHubRepoContext, public readonly environment: Environment) { 11 | const state = hasWritePermission(gitHubRepoContext.permissionLevel) 12 | ? vscode.TreeItemCollapsibleState.Collapsed 13 | : vscode.TreeItemCollapsibleState.None; 14 | super(environment.name, state); 15 | 16 | this.contextValue = "environment"; 17 | } 18 | 19 | getNodes(): SettingsExplorerNode[] { 20 | return [ 21 | new EnvironmentSecretsNode(this.gitHubRepoContext, this.environment), 22 | new EnvironmentVariablesNode(this.gitHubRepoContext, this.environment) 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/treeViews/settings/environmentSecretsNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {Environment} from "../../model"; 4 | import {EmptyNode} from "./emptyNode"; 5 | import {SecretNode} from "./secretNode"; 6 | 7 | export type EnvironmentSecretsCommandArgs = Pick; 8 | 9 | export class EnvironmentSecretsNode extends vscode.TreeItem { 10 | constructor(public readonly gitHubRepoContext: GitHubRepoContext, public readonly environment: Environment) { 11 | super("Secrets", vscode.TreeItemCollapsibleState.Collapsed); 12 | 13 | this.iconPath = new vscode.ThemeIcon("lock"); 14 | 15 | this.contextValue = "environment-secrets"; 16 | } 17 | 18 | async getSecrets(): Promise<(SecretNode | EmptyNode)[]> { 19 | let secrets: SecretNode[] = []; 20 | try { 21 | secrets = await this.gitHubRepoContext.client.paginate( 22 | this.gitHubRepoContext.client.actions.listEnvironmentSecrets, 23 | { 24 | owner: this.gitHubRepoContext.owner, 25 | repo: this.gitHubRepoContext.name, 26 | environment_name: this.environment.name, 27 | per_page: 100 28 | }, 29 | response => response.data.map(s => new SecretNode(this.gitHubRepoContext, s, this.environment)) 30 | ); 31 | } catch (e) { 32 | await vscode.window.showErrorMessage((e as Error).message); 33 | } 34 | 35 | if (!secrets || secrets.length === 0) { 36 | return [new EmptyNode("No environment secrets defined")]; 37 | } 38 | 39 | return secrets; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/treeViews/settings/environmentVariablesNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {Environment} from "../../model"; 4 | import {EmptyNode} from "./emptyNode"; 5 | import {VariableNode} from "./variableNode"; 6 | 7 | export type EnvironmentVariablesCommandArgs = Pick; 8 | 9 | export class EnvironmentVariablesNode extends vscode.TreeItem { 10 | constructor(public readonly gitHubRepoContext: GitHubRepoContext, public readonly environment: Environment) { 11 | super("Variables", vscode.TreeItemCollapsibleState.Collapsed); 12 | 13 | this.iconPath = new vscode.ThemeIcon("symbol-text"); 14 | 15 | this.contextValue = "environment-variables"; 16 | } 17 | 18 | async getVariables(): Promise<(VariableNode | EmptyNode)[]> { 19 | let variables: VariableNode[] = []; 20 | try { 21 | variables = await this.gitHubRepoContext.client.paginate( 22 | this.gitHubRepoContext.client.actions.listEnvironmentVariables, 23 | { 24 | owner: this.gitHubRepoContext.owner, 25 | repo: this.gitHubRepoContext.name, 26 | environment_name: this.environment.name, 27 | per_page: 100 28 | }, 29 | response => response.data.map(v => new VariableNode(this.gitHubRepoContext, v, this.environment)) 30 | ); 31 | } catch (e) { 32 | await vscode.window.showErrorMessage((e as Error).message); 33 | } 34 | 35 | if (!variables || variables.length === 0) { 36 | return [new EmptyNode("No environment variables defined")]; 37 | } 38 | 39 | return variables; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/treeViews/settings/environmentsNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {EnvironmentNode} from "./environmentNode"; 4 | 5 | export class EnvironmentsNode extends vscode.TreeItem { 6 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 7 | super("Environments", vscode.TreeItemCollapsibleState.Collapsed); 8 | 9 | this.iconPath = new vscode.ThemeIcon("server-environment"); 10 | } 11 | 12 | async getEnvironments(): Promise { 13 | const result = await this.gitHubRepoContext.client.repos.getAllEnvironments({ 14 | owner: this.gitHubRepoContext.owner, 15 | repo: this.gitHubRepoContext.name 16 | }); 17 | 18 | const data = result.data.environments || []; 19 | return data.map(e => new EnvironmentNode(this.gitHubRepoContext, e)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/treeViews/settings/orgSecretsNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {OrgSecret} from "../../model"; 4 | import {EmptyNode} from "./emptyNode"; 5 | import {SecretNode} from "./secretNode"; 6 | 7 | export class OrgSecretsNode extends vscode.TreeItem { 8 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 9 | super("Organization Secrets", vscode.TreeItemCollapsibleState.Collapsed); 10 | 11 | this.contextValue = "org-secrets"; 12 | } 13 | 14 | async getSecrets(): Promise { 15 | let secrets: OrgSecret[] = []; 16 | try { 17 | secrets = await this.gitHubRepoContext.client.paginate("GET /repos/{owner}/{repo}/actions/organization-secrets", { 18 | owner: this.gitHubRepoContext.owner, 19 | repo: this.gitHubRepoContext.name, 20 | per_page: 100 21 | }); 22 | } catch (e) { 23 | await vscode.window.showErrorMessage((e as Error).message); 24 | } 25 | 26 | if (!secrets || secrets.length === 0) { 27 | return [new EmptyNode("No organization secrets shared with this repository")]; 28 | } 29 | 30 | return secrets.map(s => new SecretNode(this.gitHubRepoContext, s, undefined, true)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/treeViews/settings/orgVariablesNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {OrgVariable} from "../../model"; 4 | import {EmptyNode} from "./emptyNode"; 5 | import {VariableNode} from "./variableNode"; 6 | 7 | export class OrgVariablesNode extends vscode.TreeItem { 8 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 9 | super("Organization Variables", vscode.TreeItemCollapsibleState.Collapsed); 10 | 11 | this.contextValue = "org-variables"; 12 | } 13 | 14 | async getVariables(): Promise { 15 | let variables: OrgVariable[] = []; 16 | try { 17 | variables = await this.gitHubRepoContext.client.paginate( 18 | "GET /repos/{owner}/{repo}/actions/organization-variables", 19 | { 20 | owner: this.gitHubRepoContext.owner, 21 | repo: this.gitHubRepoContext.name, 22 | per_page: 100 23 | } 24 | ); 25 | } catch (e) { 26 | await vscode.window.showErrorMessage((e as Error).message); 27 | } 28 | 29 | if (!variables || variables.length === 0) { 30 | return [new EmptyNode("No organization variables shared with this repository")]; 31 | } 32 | 33 | return variables.map(s => new VariableNode(this.gitHubRepoContext, s, undefined, true)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/treeViews/settings/repoSecretsNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {EmptyNode} from "./emptyNode"; 4 | import {SecretNode} from "./secretNode"; 5 | 6 | export type RepoSecretsCommandArgs = Pick; 7 | 8 | export class RepoSecretsNode extends vscode.TreeItem { 9 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 10 | super("Repository Secrets", vscode.TreeItemCollapsibleState.Collapsed); 11 | 12 | this.contextValue = "secrets"; 13 | } 14 | 15 | async getSecrets(): Promise { 16 | let secrets: SecretNode[] = []; 17 | try { 18 | secrets = await this.gitHubRepoContext.client.paginate( 19 | this.gitHubRepoContext.client.actions.listRepoSecrets, 20 | { 21 | owner: this.gitHubRepoContext.owner, 22 | repo: this.gitHubRepoContext.name, 23 | per_page: 100 24 | }, 25 | response => response.data.map(s => new SecretNode(this.gitHubRepoContext, s)) 26 | ); 27 | } catch (e) { 28 | await vscode.window.showErrorMessage((e as Error).message); 29 | } 30 | 31 | if (!secrets || secrets.length === 0) { 32 | return [new EmptyNode("No repository secrets defined")]; 33 | } 34 | 35 | return secrets; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/treeViews/settings/repoVariablesNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {EmptyNode} from "./emptyNode"; 4 | import {VariableNode} from "./variableNode"; 5 | 6 | export type RepoVariablesCommandArgs = Pick; 7 | 8 | export class RepoVariablesNode extends vscode.TreeItem { 9 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 10 | super("Repository Variables", vscode.TreeItemCollapsibleState.Collapsed); 11 | 12 | this.contextValue = "repo-variables"; 13 | } 14 | 15 | async getVariables(): Promise { 16 | let variables: VariableNode[] = []; 17 | try { 18 | variables = await this.gitHubRepoContext.client.paginate( 19 | this.gitHubRepoContext.client.actions.listRepoVariables, 20 | { 21 | owner: this.gitHubRepoContext.owner, 22 | repo: this.gitHubRepoContext.name, 23 | per_page: 100 24 | }, 25 | response => response.data.map(s => new VariableNode(this.gitHubRepoContext, s)) 26 | ); 27 | } catch (e) { 28 | await vscode.window.showErrorMessage((e as Error).message); 29 | } 30 | 31 | if (!variables || variables.length === 0) { 32 | return [new EmptyNode("No repository variables defined")]; 33 | } 34 | 35 | return variables; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/treeViews/settings/secretNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {Environment, EnvironmentSecret, OrgSecret, RepoSecret} from "../../model"; 4 | 5 | export type SecretCommandArgs = Pick; 6 | 7 | export class SecretNode extends vscode.TreeItem { 8 | constructor(gitHubRepoContext: GitHubRepoContext, secret: RepoSecret); 9 | constructor(gitHubRepoContext: GitHubRepoContext, secret: EnvironmentSecret, environment: Environment); 10 | constructor(githubRepoContext: GitHubRepoContext, secret: OrgSecret, environment: undefined, org: true); 11 | constructor( 12 | public readonly gitHubRepoContext: GitHubRepoContext, 13 | public readonly secret: RepoSecret | EnvironmentSecret | OrgSecret, 14 | public readonly environment?: Environment, 15 | public readonly org?: boolean 16 | ) { 17 | super(secret.name); 18 | 19 | this.contextValue = environment ? "env-secret" : org ? "org-secret" : "repo-secret"; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/treeViews/settings/secretsNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {OrgSecretsNode} from "./orgSecretsNode"; 4 | import {RepoSecretsNode} from "./repoSecretsNode"; 5 | import {SettingsExplorerNode} from "./types"; 6 | 7 | export class SecretsNode extends vscode.TreeItem { 8 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 9 | super("Secrets", vscode.TreeItemCollapsibleState.Collapsed); 10 | 11 | this.iconPath = new vscode.ThemeIcon("lock"); 12 | } 13 | 14 | get nodes(): SettingsExplorerNode[] { 15 | if (this.gitHubRepoContext.organizationOwned) { 16 | return [new RepoSecretsNode(this.gitHubRepoContext), new OrgSecretsNode(this.gitHubRepoContext)]; 17 | } 18 | 19 | return [new RepoSecretsNode(this.gitHubRepoContext)]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/treeViews/settings/settingsRepoNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {EnvironmentsNode} from "./environmentsNode"; 4 | import {GitHubRepoContext} from "../../git/repository"; 5 | import {hasWritePermission} from "../../git/repository-permissions"; 6 | import {SecretsNode} from "./secretsNode"; 7 | import {SettingsExplorerNode} from "./types"; 8 | import {VariablesNode} from "./variablesNode"; 9 | 10 | export class SettingsRepoNode extends vscode.TreeItem { 11 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 12 | super(gitHubRepoContext.name, vscode.TreeItemCollapsibleState.Collapsed); 13 | 14 | this.contextValue = "settings-repo"; 15 | } 16 | 17 | getSettings(): SettingsExplorerNode[] { 18 | return getSettingNodes(this.gitHubRepoContext); 19 | } 20 | } 21 | 22 | export function getSettingNodes(gitHubContext: GitHubRepoContext): SettingsExplorerNode[] { 23 | const nodes: SettingsExplorerNode[] = []; 24 | 25 | nodes.push(new EnvironmentsNode(gitHubContext)); 26 | 27 | if (hasWritePermission(gitHubContext.permissionLevel)) { 28 | nodes.push(new SecretsNode(gitHubContext)); 29 | nodes.push(new VariablesNode(gitHubContext)); 30 | } 31 | 32 | return nodes; 33 | } 34 | -------------------------------------------------------------------------------- /src/treeViews/settings/types.ts: -------------------------------------------------------------------------------- 1 | import {EmptyNode} from "./emptyNode"; 2 | import {EnvironmentNode} from "./environmentNode"; 3 | import {EnvironmentSecretsNode} from "./environmentSecretsNode"; 4 | import {EnvironmentsNode} from "./environmentsNode"; 5 | import {VariableNode} from "./variableNode"; 6 | import {EnvironmentVariablesNode} from "./environmentVariablesNode"; 7 | import {SecretNode} from "./secretNode"; 8 | import {SecretsNode} from "./secretsNode"; 9 | import {VariablesNode} from "./variablesNode"; 10 | import {RepoVariablesNode} from "./repoVariablesNode"; 11 | import {OrgVariablesNode} from "./orgVariablesNode"; 12 | import {OrgSecretsNode} from "./orgSecretsNode"; 13 | import {RepoSecretsNode} from "./repoSecretsNode"; 14 | import {GitHubAPIUnreachableNode} from "../shared/gitHubApiUnreachableNode"; 15 | 16 | export type SettingsExplorerNode = 17 | | SecretsNode 18 | | SecretNode 19 | | EnvironmentsNode 20 | | EnvironmentNode 21 | | EnvironmentSecretsNode 22 | | EnvironmentVariablesNode 23 | | OrgSecretsNode 24 | | OrgVariablesNode 25 | | RepoSecretsNode 26 | | RepoVariablesNode 27 | | VariableNode 28 | | VariablesNode 29 | | EmptyNode 30 | | GitHubAPIUnreachableNode; 31 | -------------------------------------------------------------------------------- /src/treeViews/settings/variableNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {Environment, EnvironmentVariable, OrgVariable, RepoVariable} from "../../model"; 4 | 5 | export type VariableCommandArgs = Pick; 6 | 7 | export class VariableNode extends vscode.TreeItem { 8 | constructor(gitHubRepoContext: GitHubRepoContext, variable: RepoVariable); 9 | constructor(gitHubRepoContext: GitHubRepoContext, variable: EnvironmentVariable, environment: Environment); 10 | constructor(githubRepoContext: GitHubRepoContext, variable: OrgVariable, environment: undefined, org: true); 11 | constructor( 12 | public readonly gitHubRepoContext: GitHubRepoContext, 13 | public readonly variable: EnvironmentVariable | RepoVariable | OrgVariable, 14 | public readonly environment?: Environment, 15 | public readonly org?: boolean 16 | ) { 17 | super(variable.name); 18 | this.description = variable.value; 19 | 20 | this.contextValue = environment ? "env-variable" : org ? "org-variable" : "repo-variable"; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/treeViews/settings/variablesNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {OrgVariablesNode} from "./orgVariablesNode"; 4 | import {RepoVariablesNode} from "./repoVariablesNode"; 5 | import {SettingsExplorerNode} from "./types"; 6 | 7 | export class VariablesNode extends vscode.TreeItem { 8 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 9 | super("Variables", vscode.TreeItemCollapsibleState.Collapsed); 10 | 11 | this.iconPath = new vscode.ThemeIcon("symbol-text"); 12 | } 13 | 14 | get nodes(): SettingsExplorerNode[] { 15 | if (this.gitHubRepoContext.organizationOwned) { 16 | return [new RepoVariablesNode(this.gitHubRepoContext), new OrgVariablesNode(this.gitHubRepoContext)]; 17 | } 18 | return [new RepoVariablesNode(this.gitHubRepoContext)]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/treeViews/shared/attemptNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {WorkflowRunAttempt} from "../../store/workflowRun"; 4 | import {getIconForWorkflowRun} from "../icons"; 5 | import {getEventString, getStatusString} from "./runTooltipHelper"; 6 | import {WorkflowJobNode} from "./workflowJobNode"; 7 | 8 | export class AttemptNode extends vscode.TreeItem { 9 | constructor(private gitHubRepoContext: GitHubRepoContext, private attempt: WorkflowRunAttempt) { 10 | super(`Attempt #${attempt.attempt}`, vscode.TreeItemCollapsibleState.Collapsed); 11 | 12 | this.iconPath = getIconForWorkflowRun(this.attempt.run); 13 | this.tooltip = this.getTooltip(); 14 | } 15 | 16 | getTooltip(): vscode.MarkdownString { 17 | let markdownString = `#${this.attempt.attempt}: `; 18 | 19 | markdownString += getStatusString(this.attempt); 20 | markdownString += `\n\n`; 21 | markdownString += getEventString(this.attempt); 22 | 23 | return new vscode.MarkdownString(markdownString); 24 | } 25 | 26 | async getJobs(): Promise { 27 | const jobs = await this.attempt.jobs(); 28 | 29 | return jobs.map(job => new WorkflowJobNode(this.gitHubRepoContext, job)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/treeViews/shared/authenticationNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export class AuthenticationNode extends vscode.TreeItem { 4 | constructor() { 5 | super("Please sign-in in the Accounts menu."); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/treeViews/shared/errorNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export class ErrorNode extends vscode.TreeItem { 4 | constructor(message: string) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/treeViews/shared/gitHubApiUnreachableNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | /** 4 | * Shown when no calls to the github API can be made. 5 | */ 6 | export class GitHubAPIUnreachableNode extends vscode.TreeItem { 7 | constructor() { 8 | super("Cannot reach GitHub API"); 9 | this.iconPath = new vscode.ThemeIcon("notebook-state-error"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/treeViews/shared/noGitHubRepositoryNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | /** 4 | * When no github.com remote can be found in the current workspace. 5 | */ 6 | export class NoGitHubRepositoryNode extends vscode.TreeItem { 7 | constructor() { 8 | super("Did not find a github.com repository"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/treeViews/shared/noWorkflowJobsNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | /** 4 | * When no github.com remote can be found in the current workspace. 5 | */ 6 | export class NoWorkflowJobsNode extends vscode.TreeItem { 7 | constructor() { 8 | super("No workflow jobs"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/treeViews/shared/previousAttemptsNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {WorkflowRun} from "../../store/workflowRun"; 4 | import {AttemptNode} from "./attemptNode"; 5 | 6 | export class PreviousAttemptsNode extends vscode.TreeItem { 7 | constructor(private gitHubRepoContext: GitHubRepoContext, private run: WorkflowRun) { 8 | super("Previous attempts", vscode.TreeItemCollapsibleState.Collapsed); 9 | } 10 | 11 | async getAttempts(): Promise { 12 | const attempts = await this.run.attempts(); 13 | return attempts.map(attempt => new AttemptNode(this.gitHubRepoContext, attempt)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/treeViews/shared/runTooltipHelper.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import localizedFormat from "dayjs/plugin/localizedFormat.js"; 3 | import relativeTime from "dayjs/plugin/relativeTime.js"; 4 | import duration from "dayjs/plugin/duration.js"; 5 | import {WorkflowRun, WorkflowRunAttempt} from "../../store/workflowRun"; 6 | 7 | dayjs.extend(duration); 8 | dayjs.extend(localizedFormat); 9 | dayjs.extend(relativeTime); 10 | 11 | // Returns a string like "**Succeeded** in **1m 2s**" 12 | // For use in markdown tooltip 13 | export function getStatusString(item: WorkflowRun | WorkflowRunAttempt, capitalize = false): string { 14 | let statusText = item.run.conclusion || item.run.status || ""; 15 | switch (statusText) { 16 | case "success": 17 | statusText = "succeeded"; 18 | break; 19 | case "failure": 20 | statusText = "failed"; 21 | break; 22 | } 23 | 24 | statusText = statusText.replace("_", " "); 25 | 26 | if (capitalize) { 27 | statusText = statusText.charAt(0).toUpperCase() + statusText.slice(1); 28 | } 29 | 30 | statusText = `**${statusText}**`; 31 | 32 | if (item.run.conclusion && item.run.conclusion !== "skipped") { 33 | const duration = dayjs.duration(item.duration()); 34 | // Format and remove leading 0's 35 | const formattedDuration = duration 36 | .format("D[d] H[h] m[m] s[s]") 37 | .replace(/^0d /, "") 38 | .replace(/^0h /, "") 39 | .replace(/^0m /, ""); 40 | statusText += ` in **${formattedDuration}**`; 41 | } 42 | 43 | return statusText; 44 | } 45 | 46 | // Returns a string like "Manually run by [user](user_url) 4 minutes ago *(December 31, 1969 7:00 PM)*" 47 | // For use in markdown tooltip 48 | export function getEventString(item: WorkflowRun | WorkflowRunAttempt): string { 49 | let eventString = "Triggered"; 50 | if (item.hasPreviousAttempts) { 51 | eventString = "Re-run"; 52 | } else { 53 | const event = item.run.event; 54 | if (event) { 55 | switch (event) { 56 | case "workflow_dispatch": 57 | eventString = "Manually triggered"; 58 | break; 59 | case "dynamic": 60 | eventString = "Triggered"; 61 | break; 62 | default: 63 | eventString = "Triggered via " + event.replace("_", " "); 64 | } 65 | } 66 | } 67 | 68 | if (item.run.triggering_actor) { 69 | eventString += ` by [${item.run.triggering_actor.login}](${item.run.triggering_actor.html_url})`; 70 | } 71 | 72 | if (item.run.run_started_at) { 73 | const started_at = dayjs(item.run.run_started_at); 74 | eventString += ` ${started_at.fromNow()} *(${started_at.format("LLL")})*`; 75 | } 76 | 77 | return eventString; 78 | } 79 | -------------------------------------------------------------------------------- /src/treeViews/shared/workflowJobNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {WorkflowJob} from "../../store/WorkflowJob"; 4 | import {getIconForWorkflowRun} from "../icons"; 5 | import {WorkflowStepNode} from "../workflows/workflowStepNode"; 6 | 7 | export class WorkflowJobNode extends vscode.TreeItem { 8 | constructor(public readonly gitHubRepoContext: GitHubRepoContext, public readonly job: WorkflowJob) { 9 | super( 10 | job.job.name, 11 | (job.job.steps && job.job.steps.length > 0 && vscode.TreeItemCollapsibleState.Collapsed) || undefined 12 | ); 13 | 14 | this.contextValue = "job"; 15 | if (this.job.job.status === "completed") { 16 | this.contextValue += " completed"; 17 | } 18 | 19 | this.iconPath = getIconForWorkflowRun(this.job.job); 20 | } 21 | 22 | hasSteps(): boolean { 23 | return !!(this.job.job.steps && this.job.job.steps.length > 0); 24 | } 25 | 26 | getSteps(): WorkflowStepNode[] { 27 | return (this.job.job.steps || []).map(s => new WorkflowStepNode(this.gitHubRepoContext, this.job, s)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/treeViews/shared/workflowRunNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {GitHubRepoContext} from "../../git/repository"; 4 | import {RunStore} from "../../store/store"; 5 | import {WorkflowRun} from "../../store/workflowRun"; 6 | import {getIconForWorkflowRun} from "../icons"; 7 | import {getEventString, getStatusString} from "./runTooltipHelper"; 8 | import {NoWorkflowJobsNode} from "./noWorkflowJobsNode"; 9 | import {PreviousAttemptsNode} from "./previousAttemptsNode"; 10 | import {WorkflowJobNode} from "./workflowJobNode"; 11 | 12 | export type WorkflowRunCommandArgs = Pick; 13 | 14 | export class WorkflowRunNode extends vscode.TreeItem { 15 | constructor( 16 | public readonly store: RunStore, 17 | public readonly gitHubRepoContext: GitHubRepoContext, 18 | public run: WorkflowRun, 19 | public readonly workflowName?: string 20 | ) { 21 | super(WorkflowRunNode._getLabel(run, workflowName), vscode.TreeItemCollapsibleState.Collapsed); 22 | 23 | this.updateRun(run); 24 | } 25 | 26 | updateRun(run: WorkflowRun) { 27 | this.run = run; 28 | this.label = WorkflowRunNode._getLabel(run, this.workflowName); 29 | 30 | this.contextValue = this.run.contextValue(this.gitHubRepoContext.permissionLevel); 31 | 32 | this.iconPath = getIconForWorkflowRun(this.run.run); 33 | this.tooltip = this.getTooltip(); 34 | } 35 | 36 | async getJobs(): Promise<(WorkflowJobNode | NoWorkflowJobsNode | PreviousAttemptsNode)[]> { 37 | const jobs = await this.run.jobs(); 38 | 39 | const children: (WorkflowJobNode | NoWorkflowJobsNode | PreviousAttemptsNode)[] = jobs.map( 40 | job => new WorkflowJobNode(this.gitHubRepoContext, job) 41 | ); 42 | 43 | if (this.run.hasPreviousAttempts) { 44 | children.push(new PreviousAttemptsNode(this.gitHubRepoContext, this.run)); 45 | } 46 | 47 | return children; 48 | } 49 | 50 | getTooltip(): vscode.MarkdownString { 51 | let markdownString = ""; 52 | 53 | if (this.run.hasPreviousAttempts && this.run.run.run_attempt) { 54 | markdownString += `Attempt #${this.run.run.run_attempt} `; 55 | } 56 | 57 | markdownString += getStatusString(this.run, markdownString.length == 0); 58 | markdownString += `\n\n`; 59 | markdownString += getEventString(this.run); 60 | 61 | return new vscode.MarkdownString(markdownString); 62 | } 63 | 64 | private static _getLabel(run: WorkflowRun, workflowName?: string): string { 65 | return `${workflowName ? workflowName + " " : ""}#${run.run.run_number}`; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/treeViews/treeViews.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {canReachGitHubAPI} from "../api/canReachGitHubAPI"; 4 | import {executeCacheClearCommand} from "../workflow/languageServer"; 5 | import {getGitHubContext} from "../git/repository"; 6 | import {logDebug} from "../log"; 7 | import {RunStore} from "../store/store"; 8 | import {CurrentBranchTreeProvider} from "./currentBranch"; 9 | import {SettingsTreeProvider} from "./settings"; 10 | import {WorkflowsTreeProvider} from "./workflows"; 11 | 12 | export async function initTreeViews(context: vscode.ExtensionContext, store: RunStore): Promise { 13 | const workflowTreeProvider = new WorkflowsTreeProvider(store); 14 | context.subscriptions.push(vscode.window.registerTreeDataProvider("github-actions.workflows", workflowTreeProvider)); 15 | 16 | const settingsTreeProvider = new SettingsTreeProvider(); 17 | context.subscriptions.push(vscode.window.registerTreeDataProvider("github-actions.settings", settingsTreeProvider)); 18 | 19 | const currentBranchTreeProvider = new CurrentBranchTreeProvider(store); 20 | context.subscriptions.push( 21 | vscode.window.registerTreeDataProvider("github-actions.current-branch", currentBranchTreeProvider) 22 | ); 23 | 24 | context.subscriptions.push( 25 | vscode.commands.registerCommand("github-actions.explorer.refresh", async () => { 26 | const canReachAPI = await canReachGitHubAPI(); 27 | await vscode.commands.executeCommand("setContext", "github-actions.internet-access", canReachAPI); 28 | 29 | const ghContext = await getGitHubContext(); 30 | const hasGitHubRepos = ghContext && ghContext.repos.length > 0; 31 | await vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos); 32 | 33 | if (canReachAPI && hasGitHubRepos) { 34 | await workflowTreeProvider.refresh(); 35 | await settingsTreeProvider.refresh(); 36 | } 37 | await executeCacheClearCommand(); 38 | }) 39 | ); 40 | 41 | context.subscriptions.push( 42 | vscode.commands.registerCommand("github-actions.explorer.current-branch.refresh", async () => { 43 | await currentBranchTreeProvider.refresh(); 44 | }) 45 | ); 46 | 47 | const gitHubContext = await getGitHubContext(); 48 | if (!gitHubContext) { 49 | logDebug("Could not register branch change event handler"); 50 | return; 51 | } 52 | 53 | for (const repo of gitHubContext.repos) { 54 | if (!repo.repositoryState) { 55 | continue; 56 | } 57 | 58 | let currentAhead = repo.repositoryState.HEAD?.ahead; 59 | let currentHeadName = repo.repositoryState.HEAD?.name; 60 | repo.repositoryState.onDidChange(async () => { 61 | // When the current head/branch changes, or the number of commits ahead changes (which indicates 62 | // a push), refresh the current-branch view 63 | if ( 64 | repo.repositoryState?.HEAD?.name !== currentHeadName || 65 | (repo.repositoryState?.HEAD?.ahead || 0) < (currentAhead || 0) 66 | ) { 67 | currentHeadName = repo.repositoryState?.HEAD?.name; 68 | currentAhead = repo.repositoryState?.HEAD?.ahead; 69 | await currentBranchTreeProvider.refresh(); 70 | } 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/treeViews/workflowRunTreeDataProvider.ts: -------------------------------------------------------------------------------- 1 | import {GitHubRepoContext} from "../git/repository"; 2 | import {WorkflowRun} from "../model"; 3 | import {RunStore} from "../store/store"; 4 | import {WorkflowRunNode} from "./shared/workflowRunNode"; 5 | 6 | export abstract class WorkflowRunTreeDataProvider { 7 | protected _runNodes = new Map(); 8 | 9 | constructor(protected store: RunStore) { 10 | this.store.event(({run}) => { 11 | // Get tree node 12 | const node = this._runNodes.get(run.run.id); 13 | if (node) { 14 | node.updateRun(run); 15 | this._updateNode(node); 16 | } 17 | }); 18 | } 19 | 20 | protected runNodes( 21 | gitHubRepoContext: GitHubRepoContext, 22 | runData: WorkflowRun[], 23 | includeWorkflowName = false 24 | ): WorkflowRunNode[] { 25 | return runData.map(runData => { 26 | const workflowRun = this.store.addRun(gitHubRepoContext, runData); 27 | const node = new WorkflowRunNode( 28 | this.store, 29 | gitHubRepoContext, 30 | workflowRun, 31 | includeWorkflowName ? workflowRun.run.name || undefined : undefined 32 | ); 33 | this._runNodes.set(runData.id, node); 34 | return node; 35 | }); 36 | } 37 | 38 | protected abstract _updateNode(node: WorkflowRunNode): void; 39 | } 40 | -------------------------------------------------------------------------------- /src/treeViews/workflows.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {canReachGitHubAPI} from "../api/canReachGitHubAPI"; 4 | import {getGitHubContext} from "../git/repository"; 5 | import {log, logDebug, logError} from "../log"; 6 | import {RunStore} from "../store/store"; 7 | import {AttemptNode} from "./shared/attemptNode"; 8 | import {AuthenticationNode} from "./shared/authenticationNode"; 9 | import {ErrorNode} from "./shared/errorNode"; 10 | import {GitHubAPIUnreachableNode} from "./shared/gitHubApiUnreachableNode"; 11 | import {NoGitHubRepositoryNode} from "./shared/noGitHubRepositoryNode"; 12 | import {NoWorkflowJobsNode} from "./shared/noWorkflowJobsNode"; 13 | import {PreviousAttemptsNode} from "./shared/previousAttemptsNode"; 14 | import {WorkflowJobNode} from "./shared/workflowJobNode"; 15 | import {WorkflowRunNode} from "./shared/workflowRunNode"; 16 | import {WorkflowRunTreeDataProvider} from "./workflowRunTreeDataProvider"; 17 | import {WorkflowNode} from "./workflows/workflowNode"; 18 | import {getWorkflowNodes, WorkflowsRepoNode} from "./workflows/workflowsRepoNode"; 19 | import {WorkflowStepNode} from "./workflows/workflowStepNode"; 20 | 21 | type WorkflowsTreeNode = 22 | | AuthenticationNode 23 | | NoGitHubRepositoryNode 24 | | WorkflowNode 25 | | WorkflowRunNode 26 | | PreviousAttemptsNode 27 | | AttemptNode 28 | | WorkflowJobNode 29 | | NoWorkflowJobsNode 30 | | WorkflowStepNode 31 | | GitHubAPIUnreachableNode; 32 | 33 | export class WorkflowsTreeProvider 34 | extends WorkflowRunTreeDataProvider 35 | implements vscode.TreeDataProvider 36 | { 37 | private _onDidChangeTreeData = new vscode.EventEmitter(); 38 | readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 39 | 40 | constructor(store: RunStore) { 41 | super(store); 42 | } 43 | 44 | protected _updateNode(node: WorkflowRunNode): void { 45 | this._onDidChangeTreeData.fire(node); 46 | } 47 | 48 | async refresh(): Promise { 49 | // Don't delete all the nodes if we can't reach GitHub API 50 | if (await canReachGitHubAPI()) { 51 | this._onDidChangeTreeData.fire(null); 52 | } else { 53 | await vscode.window.showWarningMessage("Unable to refresh, could not reach GitHub API"); 54 | } 55 | } 56 | 57 | getTreeItem(element: WorkflowsTreeNode): vscode.TreeItem | Thenable { 58 | return element; 59 | } 60 | 61 | async getChildren(element?: WorkflowsTreeNode | undefined): Promise { 62 | if (!element) { 63 | logDebug("Getting root children"); 64 | 65 | try { 66 | const gitHubContext = await getGitHubContext(); 67 | if (!gitHubContext) { 68 | logDebug("could not get github context for workflows"); 69 | return [new GitHubAPIUnreachableNode()]; 70 | } 71 | 72 | if (gitHubContext.repos.length > 0) { 73 | // Special case, if there is only one repo, return workflow nodes directly 74 | if (gitHubContext.repos.length == 1) { 75 | return getWorkflowNodes(gitHubContext.repos[0]); 76 | } 77 | 78 | return gitHubContext.repos.map(r => new WorkflowsRepoNode(r)); 79 | } 80 | 81 | log("No GitHub repositories found"); 82 | return []; 83 | } catch (e) { 84 | logError(e as Error, "Failed to get GitHub context"); 85 | 86 | if (`${(e as Error).message}`.startsWith("Could not get token from the GitHub authentication provider.")) { 87 | return [new AuthenticationNode()]; 88 | } 89 | 90 | return [new ErrorNode(`An error has occurred: ${(e as Error).message}`)]; 91 | } 92 | } 93 | 94 | if (element instanceof WorkflowsRepoNode) { 95 | return element.getWorkflows(); 96 | } else if (element instanceof WorkflowNode) { 97 | return this.getRuns(element); 98 | } else if (element instanceof WorkflowRunNode) { 99 | return element.getJobs(); 100 | } else if (element instanceof PreviousAttemptsNode) { 101 | return element.getAttempts(); 102 | } else if (element instanceof AttemptNode) { 103 | return element.getJobs(); 104 | } else if (element instanceof WorkflowJobNode) { 105 | return element.getSteps(); 106 | } 107 | 108 | return []; 109 | } 110 | 111 | private async getRuns(wfNode: WorkflowNode): Promise { 112 | logDebug("Getting workflow runs for workflow"); 113 | 114 | const result = await wfNode.gitHubRepoContext.client.actions.listWorkflowRuns({ 115 | owner: wfNode.gitHubRepoContext.owner, 116 | repo: wfNode.gitHubRepoContext.name, 117 | workflow_id: wfNode.wf.id 118 | }); 119 | 120 | return this.runNodes(wfNode.gitHubRepoContext, result.data.workflow_runs); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/treeViews/workflows/workflowNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {getPinnedWorkflows} from "../../configuration/configuration"; 4 | import {GitHubRepoContext} from "../../git/repository"; 5 | import {Workflow} from "../../model"; 6 | import {getWorkflowUri} from "../../workflow/workflow"; 7 | 8 | export class WorkflowNode extends vscode.TreeItem { 9 | constructor( 10 | public readonly gitHubRepoContext: GitHubRepoContext, 11 | public readonly wf: Workflow, 12 | public readonly workflowContext?: string 13 | ) { 14 | super(wf.name, vscode.TreeItemCollapsibleState.Collapsed); 15 | 16 | this.updateContextValue(); 17 | } 18 | 19 | updateContextValue() { 20 | this.contextValue = "workflow"; 21 | 22 | const workflowFullPath = getWorkflowUri(this.gitHubRepoContext, this.wf.path); 23 | if (workflowFullPath) { 24 | const relativeWorkflowPath = vscode.workspace.asRelativePath(workflowFullPath); 25 | if (new Set(getPinnedWorkflows()).has(relativeWorkflowPath)) { 26 | this.contextValue += " pinned"; 27 | } else { 28 | this.contextValue += " pinnable"; 29 | } 30 | } 31 | 32 | if (this.workflowContext) { 33 | this.contextValue += this.workflowContext; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/treeViews/workflows/workflowStepNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import {GitHubRepoContext} from "../../git/repository"; 3 | import {WorkflowStep} from "../../model"; 4 | import {WorkflowJob} from "../../store/WorkflowJob"; 5 | import {getIconForWorkflowStep} from "../icons"; 6 | 7 | export class WorkflowStepNode extends vscode.TreeItem { 8 | constructor( 9 | public readonly gitHubRepoContext: GitHubRepoContext, 10 | public readonly job: WorkflowJob, 11 | public readonly step: WorkflowStep 12 | ) { 13 | super(step.name); 14 | 15 | this.contextValue = "step"; 16 | if (this.step.status === "completed") { 17 | this.contextValue += " completed"; 18 | } 19 | 20 | this.iconPath = getIconForWorkflowStep(this.step); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/treeViews/workflows/workflowsRepoNode.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import {GitHubRepoContext} from "../../git/repository"; 4 | import {logDebug} from "../../log"; 5 | import {getContextStringForWorkflow, getWorkflowUri} from "../../workflow/workflow"; 6 | import {WorkflowNode} from "./workflowNode"; 7 | import {Workflow} from "../../model"; 8 | 9 | export class WorkflowsRepoNode extends vscode.TreeItem { 10 | constructor(public readonly gitHubRepoContext: GitHubRepoContext) { 11 | super(gitHubRepoContext.name, vscode.TreeItemCollapsibleState.Collapsed); 12 | 13 | this.contextValue = "wf-repo"; 14 | } 15 | 16 | async getWorkflows(): Promise { 17 | logDebug("Getting workflows"); 18 | 19 | return getWorkflowNodes(this.gitHubRepoContext); 20 | } 21 | } 22 | 23 | export async function getWorkflowNodes(gitHubRepoContext: GitHubRepoContext) { 24 | const opts = gitHubRepoContext.client.actions.listRepoWorkflows.endpoint.merge({ 25 | owner: gitHubRepoContext.owner, 26 | repo: gitHubRepoContext.name, 27 | per_page: 100 28 | }); 29 | 30 | // retrieve all pages 31 | const workflows = await gitHubRepoContext.client.paginate(opts); 32 | 33 | workflows.sort((a, b) => a.name.localeCompare(b.name)); 34 | 35 | return await Promise.all( 36 | workflows.map(async wf => { 37 | const workflowUri = getWorkflowUri(gitHubRepoContext, wf.path); 38 | const workflowContext = await getContextStringForWorkflow(workflowUri); 39 | const nameWithoutNewlines = wf.name.replace(/(\r\n|\n|\r)/gm, " "); 40 | 41 | // We are removing all newline characters from the workflow name for presentation purposes 42 | wf.name = nameWithoutNewlines; 43 | 44 | return new WorkflowNode(gitHubRepoContext, wf, workflowContext); 45 | }) 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/typings/ssh-config.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SSH Config interface 3 | * 4 | * Note that this interface atypically capitalizes field names. This is for consistency 5 | * with SSH config files. 6 | */ 7 | 8 | /** 9 | * ConfigResolvers take a config, resolve some additional data (perhaps using 10 | * a config file), and return a new Config. 11 | */ 12 | declare module "ssh-config" { 13 | export type ConfigResolver = (config: Config) => Config; 14 | 15 | export type Config = Record; 16 | 17 | export class SSHConfig { 18 | compute: (host: string) => Config; 19 | } 20 | 21 | export function parse(config: string): SSHConfig; 22 | } 23 | -------------------------------------------------------------------------------- /src/workflow/documentSelector.ts: -------------------------------------------------------------------------------- 1 | export const WorkflowSelector = { 2 | pattern: "**/.github/workflows/*.{yaml,yml}" 3 | }; 4 | -------------------------------------------------------------------------------- /src/workflow/languageServer.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as vscode from "vscode"; 3 | 4 | import {Commands} from "@actions/languageserver/commands"; 5 | import {InitializationOptions, LogLevel} from "@actions/languageserver/initializationOptions"; 6 | import {ReadFileRequest, Requests} from "@actions/languageserver/request"; 7 | import {BaseLanguageClient, LanguageClientOptions} from "vscode-languageclient"; 8 | import {LanguageClient as BrowserLanguageClient} from "vscode-languageclient/browser"; 9 | import {LanguageClient as NodeLanguageClient, ServerOptions, TransportKind} from "vscode-languageclient/node"; 10 | import {userAgent} from "../api/api"; 11 | import {getSession} from "../auth/auth"; 12 | import {getGitHubContext} from "../git/repository"; 13 | import {WorkflowSelector} from "./documentSelector"; 14 | import {getGitHubApiUri, useEnterprise} from "../configuration/configuration"; 15 | 16 | let client: BaseLanguageClient; 17 | 18 | /** Helper function determining whether we are executing with node runtime */ 19 | function isNode(): boolean { 20 | return typeof process !== "undefined" && process.versions?.node != null; 21 | } 22 | 23 | export async function initLanguageServer(context: vscode.ExtensionContext) { 24 | const session = await getSession(); 25 | 26 | const ghContext = await getGitHubContext(); 27 | const initializationOptions: InitializationOptions = { 28 | sessionToken: session?.accessToken, 29 | userAgent: userAgent, 30 | gitHubApiUrl: useEnterprise() ? getGitHubApiUri() : undefined, 31 | repos: ghContext?.repos.map(repo => ({ 32 | id: repo.id, 33 | owner: repo.owner, 34 | name: repo.name, 35 | workspaceUri: repo.workspaceUri.toString(), 36 | organizationOwned: repo.organizationOwned 37 | })), 38 | logLevel: PRODUCTION ? LogLevel.Warn : LogLevel.Debug 39 | }; 40 | 41 | const clientOptions: LanguageClientOptions = { 42 | documentSelector: [WorkflowSelector], 43 | initializationOptions: initializationOptions, 44 | progressOnInitialization: true 45 | }; 46 | 47 | // Create the language client and start the client. 48 | if (isNode()) { 49 | const debugOptions = {execArgv: ["--nolazy", "--inspect=6010"]}; 50 | 51 | const serverModule = context.asAbsolutePath(path.join("dist", "server-node.js")); 52 | const serverOptions: ServerOptions = { 53 | run: {module: serverModule, transport: TransportKind.ipc}, 54 | debug: { 55 | module: serverModule, 56 | transport: TransportKind.ipc, 57 | options: debugOptions 58 | } 59 | }; 60 | 61 | client = new NodeLanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions); 62 | } else { 63 | const serverModule = vscode.Uri.joinPath(context.extensionUri, "dist", "server-web.js"); 64 | const worker = new Worker(serverModule.toString()); 65 | client = new BrowserLanguageClient("actions-language", "GitHub Actions Language Server", clientOptions, worker); 66 | } 67 | 68 | client.onRequest(Requests.ReadFile, async (event: ReadFileRequest) => { 69 | if (typeof event?.path !== "string") { 70 | return null; 71 | } 72 | 73 | const uri = vscode.Uri.parse(event?.path); 74 | const content = await vscode.workspace.fs.readFile(uri); 75 | return new TextDecoder().decode(content); 76 | }); 77 | 78 | return client.start(); 79 | } 80 | 81 | export function deactivateLanguageServer(): Promise { 82 | if (!client) { 83 | return Promise.resolve(); 84 | } 85 | 86 | return client.stop(); 87 | } 88 | 89 | export function executeCacheClearCommand(): Promise { 90 | if (!client) { 91 | return Promise.resolve(); 92 | } 93 | 94 | return client.sendRequest("workspace/executeCommand", {command: Commands.ClearCache}); 95 | } 96 | -------------------------------------------------------------------------------- /src/workflow/workflow.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import { 4 | convertWorkflowTemplate, 5 | NoOperationTraceWriter, 6 | parseWorkflow, 7 | WorkflowTemplate 8 | } from "@actions/workflow-parser"; 9 | import {ErrorPolicy} from "@actions/workflow-parser/model/convert"; 10 | import {basename} from "path"; 11 | import {GitHubRepoContext} from "../git/repository"; 12 | 13 | export async function getContextStringForWorkflow(workflowUri: vscode.Uri): Promise { 14 | try { 15 | const content = await vscode.workspace.fs.readFile(workflowUri); 16 | const file = new TextDecoder().decode(content); 17 | 18 | const fileName = ""; 19 | 20 | const result = parseWorkflow( 21 | { 22 | name: fileName, 23 | content: file 24 | }, 25 | new NoOperationTraceWriter() 26 | ); 27 | 28 | if (result.value) { 29 | const template = await convertWorkflowTemplate(result.context, result.value, undefined, { 30 | errorPolicy: ErrorPolicy.TryConversion 31 | }); 32 | 33 | const context: string[] = []; 34 | 35 | if (template.events["repository_dispatch"]) { 36 | context.push("rdispatch"); 37 | } 38 | 39 | if (template.events["workflow_dispatch"]) { 40 | context.push("wdispatch"); 41 | } 42 | 43 | return context.join(""); 44 | } 45 | } catch (e) { 46 | // Ignore 47 | } 48 | 49 | return ""; 50 | } 51 | 52 | /** 53 | * Try to get Uri to workflow in currently open workspace folders 54 | * 55 | * @param path Path for workflow. E.g., `.github/workflows/somebuild.yaml` 56 | */ 57 | export function getWorkflowUri(gitHubRepoContext: GitHubRepoContext, path: string): vscode.Uri { 58 | return vscode.Uri.joinPath(gitHubRepoContext.workspaceUri, path); 59 | } 60 | 61 | export async function parseWorkflowFile(uri: vscode.Uri): Promise { 62 | try { 63 | const b = await vscode.workspace.fs.readFile(uri); 64 | const workflowInput = new TextDecoder().decode(b); 65 | 66 | const fileName = basename(uri.fsPath); 67 | 68 | const result = parseWorkflow( 69 | { 70 | name: fileName, 71 | content: workflowInput 72 | }, 73 | new NoOperationTraceWriter() 74 | ); 75 | 76 | if (result.value) { 77 | return await convertWorkflowTemplate(result.context, result.value); 78 | } 79 | } catch { 80 | // Ignore error here 81 | } 82 | 83 | return undefined; 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "ES2020", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "moduleResolution": "node", 11 | "outDir": "out", 12 | "resolveJsonModule": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const path = require("path"); 4 | const webpack = require("webpack"); 5 | 6 | module.exports = (env, argv) => { 7 | /**@type {import('webpack').Configuration}*/ 8 | const config = { 9 | entry: "./src/extension.ts", // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 10 | devtool: "source-map", 11 | externals: { 12 | vscode: "commonjs vscode" 13 | }, 14 | node: { 15 | __dirname: false // We need to support dirname to be able to load the language server 16 | }, 17 | plugins: [ 18 | new webpack.ProvidePlugin({ 19 | Buffer: ["buffer", "Buffer"] 20 | }), 21 | new webpack.DefinePlugin({ 22 | PRODUCTION: argv.mode === "production" 23 | }) 24 | ], 25 | resolve: { 26 | extensions: [".ts", ".js"], 27 | alias: { 28 | "universal-user-agent$": path.resolve(__dirname, "node_modules/universal-user-agent/index.js") 29 | }, 30 | fallback: { 31 | buffer: require.resolve("buffer/"), 32 | path: require.resolve("path-browserify"), 33 | crypto: require.resolve("crypto-browserify"), 34 | stream: require.resolve("stream-browserify"), 35 | timers: require.resolve("timers-browserify") 36 | } 37 | }, 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.ts$/, 42 | exclude: /node_modules/, 43 | use: [ 44 | { 45 | loader: "ts-loader", 46 | options: { 47 | compilerOptions: { 48 | sourceMap: true 49 | } 50 | } 51 | } 52 | ] 53 | }, 54 | { 55 | test: /\.js$/, 56 | enforce: "pre", 57 | use: ["source-map-loader"] 58 | }, 59 | { 60 | test: /\.m?js$/, 61 | resolve: { 62 | fullySpecified: false // disable the behaviour 63 | } 64 | }, 65 | { 66 | test: /\.node$/, 67 | use: "node-loader" 68 | } 69 | ] 70 | }, 71 | ignoreWarnings: [/Failed to parse source map/] 72 | }; 73 | 74 | const nodeConfig = { 75 | ...config, 76 | target: "node", 77 | output: { 78 | path: path.resolve(__dirname, "dist"), 79 | filename: "extension-node.js", 80 | libraryTarget: "commonjs", 81 | devtoolModuleFilenameTemplate: "../[resource-path]" 82 | } 83 | }; 84 | 85 | const webConfig = { 86 | ...config, 87 | target: "webworker", 88 | output: { 89 | path: path.resolve(__dirname, "dist"), 90 | filename: "extension-web.js", 91 | libraryTarget: "commonjs", 92 | devtoolModuleFilenameTemplate: "../[resource-path]" 93 | } 94 | }; 95 | 96 | const serverConfig = { 97 | entry: "./src/langserver.ts", 98 | devtool: "inline-source-map", 99 | externals: { 100 | vscode: "commonjs vscode" 101 | }, 102 | plugins: [ 103 | new webpack.ProvidePlugin({ 104 | Buffer: ["buffer", "Buffer"] 105 | }), 106 | new webpack.DefinePlugin({ 107 | PRODUCTION: JSON.stringify(process.env.NODE_ENV) 108 | }) 109 | ], 110 | module: { 111 | rules: [ 112 | { 113 | test: /\.ts$/, 114 | use: [ 115 | { 116 | loader: "ts-loader", 117 | options: { 118 | compilerOptions: { 119 | sourceMap: true 120 | } 121 | } 122 | } 123 | ], 124 | resolve: { 125 | fullySpecified: false 126 | } 127 | }, 128 | { 129 | test: /\.js$/, 130 | enforce: "pre", 131 | use: ["source-map-loader"] 132 | }, 133 | { 134 | test: /\.m?js$/, 135 | resolve: { 136 | fullySpecified: false // disable the behaviour 137 | } 138 | } 139 | ] 140 | }, 141 | ignoreWarnings: [/Failed to parse source map/], 142 | resolve: { 143 | extensions: [".ts", ".tsx", ".js"], 144 | extensionAlias: { 145 | ".ts": [".js", ".ts"], 146 | ".cts": [".cjs", ".cts"], 147 | ".mts": [".mjs", ".mts"] 148 | }, 149 | fallback: { 150 | buffer: require.resolve("buffer/"), 151 | path: require.resolve("path-browserify") 152 | } 153 | } 154 | }; 155 | 156 | const serverNodeConfig = { 157 | ...serverConfig, 158 | target: "node", 159 | output: { 160 | path: path.resolve(__dirname, "dist"), 161 | filename: "server-node.js", 162 | libraryTarget: "commonjs", 163 | devtoolModuleFilenameTemplate: "../[resource-path]" 164 | } 165 | }; 166 | 167 | const serverWebConfig = { 168 | ...serverConfig, 169 | target: "webworker", 170 | output: { 171 | path: path.resolve(__dirname, "dist"), 172 | filename: "server-web.js", 173 | devtoolModuleFilenameTemplate: "../[resource-path]" 174 | } 175 | }; 176 | 177 | return [nodeConfig, webConfig, serverNodeConfig, serverWebConfig]; 178 | }; 179 | --------------------------------------------------------------------------------