diff --git a/.codespellrc b/.codespellrc index 13c05a80..21ec3a65 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,5 +1,5 @@ [codespell] -skip = .git,*.pdf,*.svg,pnpm-lock.yaml,yarn.lock +skip = .git,*.pdf,*.svg,pnpm-lock.yaml,yarn.lock,package-lock.json # some modules, parts of regexes, and variable names to ignore, some # misspellings in fixtures/external responses we do not own ignore-words-list = caf,bu,nwo,nd,kernal,crate,unparseable,couldn,defintions diff --git a/.github/workflows/extension.yml b/.github/workflows/extension.yml index 8dab185e..5e1006dc 100644 --- a/.github/workflows/extension.yml +++ b/.github/workflows/extension.yml @@ -76,9 +76,12 @@ jobs: - name: Update version numbers in task.json run: | - echo "`jq '.version.Major=${{ steps.gitversion.outputs.major }}' extension/tasks/dependabot/dependabotV1/task.json`" > extension/tasks/dependabot/dependabotV1/task.json - echo "`jq '.version.Minor=${{ steps.gitversion.outputs.minor }}' extension/tasks/dependabot/dependabotV1/task.json`" > extension/tasks/dependabot/dependabotV1/task.json + echo "`jq '.version.Major=1' extension/tasks/dependabot/dependabotV1/task.json`" > extension/tasks/dependabot/dependabotV1/task.json + echo "`jq '.version.Minor=34' extension/tasks/dependabot/dependabotV1/task.json`" > extension/tasks/dependabot/dependabotV1/task.json echo "`jq '.version.Patch=${{ github.run_number }}' extension/tasks/dependabot/dependabotV1/task.json`" > extension/tasks/dependabot/dependabotV1/task.json + echo "`jq '.version.Major=${{ steps.gitversion.outputs.major }}' extension/tasks/dependabot/dependabotV2/task.json`" > extension/tasks/dependabot/dependabotV2/task.json + echo "`jq '.version.Minor=${{ steps.gitversion.outputs.minor }}' extension/tasks/dependabot/dependabotV2/task.json`" > extension/tasks/dependabot/dependabotV2/task.json + echo "`jq '.version.Patch=${{ github.run_number }}' extension/tasks/dependabot/dependabotV2/task.json`" > extension/tasks/dependabot/dependabotV2/task.json - name: Create Extension (dev) run: > diff --git a/README.md b/README.md index 0fdbf255..85baf0ba 100644 --- a/README.md +++ b/README.md @@ -8,114 +8,210 @@ This repository contains tools for updating dependencies in Azure DevOps reposit In this repository you'll find: -1. Dependabot [updater](./updater) in Ruby. See [docs](./docs/updater.md). -2. Dockerfile and build/image for running the updater via Docker [here](./updater/Dockerfile). -3. Dependabot [server](./server/) in .NET/C#. See [docs](./docs/server.md). -4. Azure DevOps [Extension](https://marketplace.visualstudio.com/items?itemName=tingle-software.dependabot) and [source](./extension). See [docs](./docs/extension.md). +1. Azure DevOps [Extension](https://marketplace.visualstudio.com/items?itemName=tingle-software.dependabot), [source code](./extension) and [docs](./docs/extension.md). +1. Dependabot Server, [source code](./server/) and [docs](./docs/server.md). +1. Dependabot Updater image, [Dockerfile](./updater/Dockerfile), [source code](./updater/) and [docs](./docs/updater.md). **(Deprecated since v2.0)** -> The hosted version is available to sponsors (most, but not all). It includes hustle free runs where the infrastructure is maintained for you. Much like the GitHub hosted version. Alternatively, you can run and host your own [server](./docs/server.md). Once you sponsor, you can send out an email to an maintainer or wait till they reach out. This is meant to ease the burden until GitHub/Azure/Microsoft can get it working natively (which could also be never) and hopefully for free. +> [!IMPORTANT] +> This project is currently undergoing a major version increment (V1 → V2); See the [migration guide](./docs/migrations/v1-to-v2.md#summary-of-changes-v1--v2) for more details and progress updates. + +## Table of Contents +- [Getting started](#getting-started) +- [Using a configuration file](#using-a-configuration-file) +- [Configuring private feeds and registries](#configuring-private-feeds-and-registries) +- [Configuring security advisories and known vulnerabilities](#configuring-security-advisories-and-known-vulnerabilities) +- [Configuring experiments](#configuring-experiments) +- [Unsupported features and configurations](#unsupported-features-and-configurations) + * [Extension Task](#extension-task) + + [dependabot@V2](#dependabotv2) + + [dependabot@V1](#dependabotv1) + * [Updater Docker image](#updater-docker-image) + * [Server](#server) +- [Migration Guide](#migration-guide) +- [Development Guide](#development-guide) +- [Acknowledgements](#acknowledgements) +- [Issues & Comments](#issues-amp-comments) + +## Getting started + +Unlike the GitHub-hosted version, Dependabot for Azure DevOps must be explicitly setup in your organisation; creating a `dependabot.yml` file alone is **not** enough to enable updates. There are two ways to enable Dependabot, using: + +- [Azure DevOps Extension](https://marketplace.visualstudio.com/items?itemName=tingle-software.dependabot) - Ideal if you want to get Dependabot running with minimal administrative effort. The extension can run directly inside your existing pipeline agents and doesn't require hosting of any additional services. Because the extension runs in pipelines, this option does **not** scale well if you have a large number of projects and repositories. + +- [Hosted Server](./docs/server.md) - Ideal if you have a large number of projects and repositories or prefer to run Dependabot as a managed service instead of using pipeline agents. See [why should I use the server?](./docs/server.md#why-should-i-use-the-server) for more info. + +> [!NOTE] +> A hosted version is available to sponsors (most, but not all). It includes hassle free runs where the infrastructure is maintained for you. Much like the GitHub hosted version. Alternatively, you can run and host your own [self-hosted server](./docs/server.md). Once you sponsor, you can send out an email to a maintainer or wait till they reach out. This is meant to ease the burden until GitHub/Azure/Microsoft can get it working natively (which could also be never) and hopefully for free. ## Using a configuration file -Similar to the GitHub native version where you add a `.azuredevops/dependabot.yml` or `.github/dependabot.yml` file, this repository adds support for the same official [configuration options](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file) via a file located at `.azuredevops/dependabot.yml` or `.github/dependabot.yml`. This support is only available in the Azure DevOps extension and the [managed version](https://managd.dev). However, the extension does not currently support automatically picking up the file, a pipeline is still required. See [docs](./extension/README.md#usage). +Similar to the GitHub-hosted version, Dependabot is configured using a [dependabot.yml file](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file) located at `.azuredevops/dependabot.yml` or `.github/dependabot.yml` in your repository. -We are well aware that ignore conditions are not explicitly passed and passed on from the extension/server to the container. It is intentional. The ruby script in the docker container does it automatically. If you are having issues, search for related issues such as before creating a new issue. You can also test against various reproductions such as +Most [official configuration options](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file) are supported since V2; Earlier versions have several limitations, see [unsupported features and configurations](#unsupported-features-and-configurations) for more. -## Credentials for private registries and feeds +## Configuring private feeds and registries -Besides accessing the repository only, sometimes private feeds/registries may need to be accessed. -For example a private NuGet feed or a company internal docker registry. +Besides accessing the repository, sometimes private feeds/registries may need to be accessed. For example a private NuGet feed or a company internal docker registry. -Adding configuration options for private registries is setup in `dependabot.yml` -according to the dependabot [description](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#configuration-options-for-private-registries). +Private registries are configured in `dependabot.yml`, refer to the [official documentation](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#configuration-options-for-private-registries). -Example: +Examples: ```yml version: 2 registries: - my-Extern@Release: - type: nuget-feed - url: https://dev.azure.com/organization1/_packaging/my-Extern@Release/nuget/v3/index.json - token: PAT:${{ MY_DEPENDABOT_ADO_PAT }} + + # Azure DevOps private feed, all views my-analyzers: type: nuget-feed url: https://dev.azure.com/organization2/_packaging/my-analyzers/nuget/v3/index.json - token: PAT:${{ MY_OTHER_PAT }} + token: PAT:${{ MY_DEPENDABOT_ADO_PAT }} + + # Azure DevOps private feed, "Release" view only + my-Extern@Release: + type: nuget-feed + url: https://dev.azure.com/organization1/_packaging/my-Extern@Release/nuget/v3/index.json + token: PAT:${{ MY_DEPENDABOT_ADO_PAT }} + + # Artifactory private feed using PAT artifactory: type: nuget-feed url: https://artifactory.com/api/nuget/v3/myfeed - token: PAT:${{ MY_ARTIFACTORY_PAT }} + token: PAT:${{ MY_DEPENDABOT_ARTIFACTORY_PAT }} + + # Other private feed using basic auth (username/password) telerik: type: nuget-feed url: https://nuget.telerik.com/v3/index.json username: ${{ MY_TELERIK_USERNAME }} password: ${{ MY_TELERIK_PASSWORD }} token: ${{ MY_TELERIK_USERNAME }}:${{ MY_TELERIK_PASSWORD }} + updates: ... ``` -Note: - -1. `${{ VARIABLE_NAME }}` notation is used liked described [here](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-encrypted-secrets-for-dependabot) -BUT the values will be used from Environment Variables in the pipeline/environment. Template variables are not supported for this replacement. Replacement only works for values considered secret in the registries section i.e. `username`, `password`, `token`, and `key` - -2. When using an Azure DevOps Artifact feed, only the `token` property is required. The token notation should be `PAT:${{ VARIABLE_NAME }}` otherwise the wrong authentication mechanism is used by Dependabot, see [here](https://github.com/tinglesoftware/dependabot-azure-devops/issues/50) for more details. -When working with Azure DevOps Artifacts, some extra permission steps need to be done: - - 1. The PAT should have *Packaging Read* permission. - 2. The user owning the PAT must be granted permissions to access the feed either directly or via a group. An easy way for this is to give `Contributor` permissions the `[{project_name}]\Contributors` group under the `Feed Settings -> Permissions` page. The page has the url format: `https://dev.azure.com/{organization}/{project}/_packaging?_a=settings&feed={feed-name}&view=permissions`. - -3. When using a NuGet package server secured with basic auth, the `username`, `password`, and `token` properties are all required. The token notation should be `${{ USERNAME }}:${{ PASSWORD }}`, see [here](https://github.com/tinglesoftware/dependabot-azure-devops/issues/1232#issuecomment-2247616424) for more details. - -4. When your project contains a `nuget.config` file with custom package source configuration, the `key` property is required for each nuget-feed registry. The key must match between `dependabot.yml` and `nuget.config` otherwise the package source will be duplicated, package source mappings will be ignored, and auth errors will occur during dependency discovery. - - If your `nuget.config` looks like this: - - ```xml - - - - - - - - - - - - - - - - - ``` - - Then your `dependabot.yml` registry should look like this: - - ```yml - version: 2 - registries: - my-org: - type: nuget-feed - key: my-organisation1-nuget - url: https://dev.azure.com/my-organization/_packaging/my-nuget-feed/nuget/v3/index.json - token: PAT:${{ MY_DEPENDABOT_ADO_PAT }} - ``` - -## Security Advisories, Vulnerabilities, and Updates - -Security-only updates ia a mechanism to only create pull requests for dependencies with vulnerabilities by updating them to the earliest available non-vulnerable version. Security updates are supported in the same way as the GitHub-hosted version. In addition, you can provide extra advisories, such as those for an internal dependency, in a JSON file via the `securityAdvisoriesFile` input e.g. `securityAdvisoriesFile: '$(Pipeline.Workspace)/advisories.json'`. A file example is available [here](./advisories-example.json). - -A GitHub access token with `public_repo` access is required to perform the GitHub GraphQL for `securityVulnerabilities`. +Note when using authentication secrets in configuration files: + +> [!IMPORTANT] +> `${{ VARIABLE_NAME }}` notation is used liked described [here](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-encrypted-secrets-for-dependabot) +BUT the values will be used from pipeline environment variables. Template variables are not supported for this replacement. Replacement only works for values considered secret in the registries section i.e. `username`, `password`, `token`, and `key` + +> [!IMPORTANT] +> When using an Azure DevOps Artifact feed, the token format must be `PAT:${{ VARIABLE_NAME }}` where `VARIABLE_NAME` is a pipeline/environment variable containing the PAT token. The PAT must: +> 1. Have `Packaging (Read)` permission. +> 2. Be issued by a user with permission to the feed either directly or via a group. An easy way for this is to give `Contributor` permissions the `[{project_name}]\Contributors` group under the `Feed Settings -> Permissions` page. The page has the url format: `https://dev.azure.com/{organization}/{project}/_packaging?_a=settings&feed={feed-name}&view=permissions`. + +> [!NOTE] +> When using `dependabot@V1` with a private feed/registry secured with basic auth, the `username`, `password`, **and** `token` properties are all required. The token format must be `${{ USERNAME }}:${{ PASSWORD }}`. + +> [!NOTE] +> When using `dependabot@V1` with a repository containing a `nuget.config` file configured with custom package sources, the `key` property is required for each registry. The key must match between `dependabot.yml` and `nuget.config` otherwise the package source will be duplicated, package source mappings will be ignored, and auth errors will occur during dependency discovery. If your `nuget.config` looks like this: +> ```xml +> +> +> +> +> +> +> +> +> +> +> +> +> +> +> +> +> ``` +> +> Then your `dependabot.yml` registry should look like this: +> ```yml +> version: 2 +> registries: +> my-org: +> type: nuget-feed +> key: my-organisation1-nuget +> url: https://dev.azure.com/my-organization/_packaging/my-nuget-feed/nuget/v3/index.json +> token: PAT:${{ MY_DEPENDABOT_ADO_PAT }} +> ``` + +## Configuring security advisories and known vulnerabilities + +Security-only updates is a mechanism to only create pull requests for dependencies with vulnerabilities by updating them to the earliest available non-vulnerable version. [Security updates are supported in the same way as the GitHub-hosted version](https://docs.github.com/en/code-security/dependabot/dependabot-security-updates/configuring-dependabot-security-updates#overriding-the-default-behavior-with-a-configuration-file) provided that a GitHub access token with `public_repo` access is provided in the `gitHubAccessToken` or `gitHubConnection` task inputs. + +You can provide extra security advisories, such as those for an internal dependency, in a JSON file via the `securityAdvisoriesFile` task input e.g. `securityAdvisoriesFile: '$(Pipeline.Workspace)/advisories.json'`. An example file is available in [./advisories-example.json](./advisories-example.json). + +## Configuring experiments +Dependabot uses an internal feature flag system called "experiments". Typically, experiments represent new features or changes in logic which are still being internally tested before becoming generally available. In some cases, you may want to opt-in to experiments to work around known issues or to opt-in to preview features ahead of general availability (GA). + +Experiments vary depending on the package ecyosystem used; They can be enabled using the `experiments` task input with a comma-seperated list of key/value pairs representing the experiments e.g. `experiments: 'tidy=true,vendor=true,goprivate=*'`. + +> [!NOTE] +> Dependabot experinment names are not [publicly] documented. For convenience, some known experiments are listed below; However, **be aware that this may be out-of-date at the time of reading.** + +
+List of known experiments from dependabot-core@0.275.0 + +|Package Ecosystem|Experiment Name|Value Type|Description| +|--|--|--|--| +| All | dedup_branch_names | true/false | | +| All | grouped_updates_experimental_rules | true/false | | +| All | grouped_security_updates_disabled | true/false | | +| All | record_ecosystem_versions | true/false | | +| All | record_update_job_unknown_error | true/false | | +| All | dependency_change_validation | true/false | | +| All | add_deprecation_warn_to_pr_message | true/false | | +| All | threaded_metadata | true/false | | +| Bundler | bundler_v1_unsupported_error | true/false | | +| Go | tidy | true/false | | +| Go | vendor | true/false | | +| Go | goprivate | string | | +| NPM and Yarn | enable_pnpm_yarn_dynamic_engine | true/false | | +| NuGet | nuget_native_analysis | true/false | | +| NuGet | nuget_dependency_solver | true/false | | + +
+ +> [!TIP] +> To find the latest list of Dependabot experiments, search the `dependabot-core` GitHub repository using queries like ["enabled?(x)"](https://github.com/search?q=repo%3Adependabot%2Fdependabot-core+%2Fenabled%5CW%5C%28.*%5C%29%2F&type=code) and ["options.fetch(x)"](https://github.com/search?q=repo%3Adependabot%2Fdependabot-core+%2Foptions%5C.fetch%5C%28.*%2C%2F&type=code). + +## Unsupported features and configurations +We aim to support all [official configuration options](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file), but there are some limitations for: + +### Extension Task + +#### `dependabot@V2` +- [`schedule`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#scheduleinterval) is ignored, use [pipeline scheduled triggers](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/scheduled-triggers?view=azure-devops&tabs=yaml#scheduled-triggers) instead. +- [Security-only updates](https://docs.github.com/en/code-security/dependabot/dependabot-security-updates/configuring-dependabot-security-updates#overriding-the-default-behavior-with-a-configuration-file) (`open-pull-requests-limit: 0`) are not supported. _(coming soon)_ + +#### `dependabot@V1` +- [`schedule`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#scheduleinterval) is ignored, use [pipeline scheduled triggers](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/scheduled-triggers?view=azure-devops&tabs=yaml#scheduled-triggers) instead. +- [`directories`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#directories) are only supported if task input `useUpdateScriptVNext: true` is set. +- [`groups`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups) are only supported if task input `useUpdateScriptVNext: true` is set. +- [`ignore`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#ignore) may not behave to official specifications unless task input `useUpdateScriptVNext: true` is set. If you are having issues, search for related issues such as before creating a new issue. +- Private feed/registry authentication may not work with all package ecyosystems. Support is _slightly_ improved when task input `useUpdateScriptVNext: true` is set, but not still not fully supported. See [problems with authentication](https://github.com/tinglesoftware/dependabot-azure-devops/discussions/1317) for more. + +### Updater Docker image +- Private feed/registry authentication may not work with all package ecyosystems. See [problems with authentication](https://github.com/tinglesoftware/dependabot-azure-devops/discussions/1317) for more. + +### Server + +- [`directories`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#directories) are not supported. +- [`groups`](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups) are not supported. +- Private feed/registry authentication may not work with all package ecyosystems. See [problems with authentication](https://github.com/tinglesoftware/dependabot-azure-devops/discussions/1317) for more. + +## Migration Guide +- [Extension Task V1 → V2](./docs/migrations/v1-to-v2) ## Development Guide If you'd like to contribute to the project or just run it locally, view our development guides for: -- [Azure DevOps extension](./docs/extension.md#development-guide) -- [Dependabot updater](./docs/updater.md#development-guide) +- [Azure DevOps Extension](./docs/extension.md#development-guide) +- [Dependabot Server](./docs/server.md#development-guide) +- [Dependabot Updater image](./docs/updater.md#development-guide) **(Deprecated since v2.0)** ## Acknowledgements @@ -129,4 +225,6 @@ The work in this repository is based on inspired and occasionally guided by some ## Issues & Comments -Please leave all comments, bugs, requests, and issues on the Issues page. We'll respond to your request ASAP! +Please leave all issues, bugs, and feature requests on the [issues page](https://github.com/tinglesoftware/dependabot-azure-devops/issues). We'll respond ASAP! + +Use the [discussions page](https://github.com/tinglesoftware/dependabot-azure-devops/discussions) for all other questions and comments. diff --git a/docs/extension.md b/docs/extension.md index 408f0755..e90fafd4 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -3,21 +3,24 @@ - [Using the extension](#using-the-extension) - [Development guide](#development-guide) - - [Getting the development environment ready](#getting-the-development-environment-ready) - - [Building the extension](#building-the-extension) - - [Installing the extension](#installing-the-extension) - - [Running the unit tests](#running-the-unit-tests) + * [Getting the development environment ready](#getting-the-development-environment-ready) + * [Building the extension](#building-the-extension) + * [Installing the extension](#installing-the-extension) + * [Running the task locally](#running-the-task-locally) + * [Running the unit tests](#running-the-unit-tests) +- [Architecture](#architecture) + * [Task V2 high-level update process diagram](#task-v2-high-level-update-process-diagram) + # Using the extension -See the extension [README.md](../extension/README.md). +Refer to the extension [README.md](../extension/README.md). # Development guide ## Getting the development environment ready -First, ensure you have [Node.js](https://docs.docker.com/engine/install/) v18+ installed. -Next, install project dependencies with npm: +Install [Node.js](https://docs.docker.com/engine/install/) v18 or higher; Install project dependencies using NPM: ```bash cd extension @@ -31,7 +34,7 @@ cd extension npm run build ``` -To generate the Azure DevOps `.vsix` extension package for testing, you'll first need to [create a publisher account](https://learn.microsoft.com/en-us/azure/devops/extend/publish/overview?view=azure-devops#create-a-publisher) on the [Visual Studio Marketplace Publishing Portal](https://marketplace.visualstudio.com/manage/createpublisher?managePageRedirect=true). After this, override your publisher ID below and generate the extension with: +To then generate the a Azure DevOps `.vsix` extension package for testing, you'll first need to [create a publisher account](https://learn.microsoft.com/en-us/azure/devops/extend/publish/overview?view=azure-devops#create-a-publisher) for the [Visual Studio Marketplace Publishing Portal](https://marketplace.visualstudio.com/manage/createpublisher?managePageRedirect=true). After this, use `npm run package` to build the package, with an override for your publisher ID: ```bash npm run package -- --overrides-file overrides.local.json --rev-version --publisher your-publisher-id-here @@ -39,17 +42,78 @@ npm run package -- --overrides-file overrides.local.json --rev-version --publish ## Installing the extension -To test the extension in Azure DevOps, you'll first need to build the extension `.vsix` file (see above). After this, [publish your extension](https://learn.microsoft.com/en-us/azure/devops/extend/publish/overview?view=azure-devops#publish-your-extension), then [install your extension](https://learn.microsoft.com/en-us/azure/devops/extend/publish/overview?view=azure-devops#install-your-extension). +To test the extension in a Azure DevOps organisation: +1. [Build the extension `.vsix` package](#building-the-extension) +1. [Publish the extension to your publisher account](https://learn.microsoft.com/en-us/azure/devops/extend/publish/overview?view=azure-devops#publish-your-extension) +1. [Share the extension with the organisation](https://learn.microsoft.com/en-us/azure/devops/extend/publish/overview?view=azure-devops#share-your-extension). ## Running the task locally - +To run the latest task version: ```bash npm start ``` +To run a specific task version: +```bash +npm run start:V1 # runs dependabotV1 task +npm run start:V2 # runs dependabotV2 task +``` ## Running the unit tests ```bash cd extension npm test ``` + +# Architecture + +## Task V2 high-level update process diagram +High-level sequence diagram illustrating how the `dependabotV2` task performs updates using [dependabot-cli](https://github.com/dependabot/cli). For more technical details, see [how dependabot-cli works](https://github.com/dependabot/cli?tab=readme-ov-file#how-it-works). + +```mermaid + sequenceDiagram + participant ext as Dependabot DevOps Extension + participant agent as DevOps Pipeline Agent + participant devops as DevOps API + participant cli as Dependabot CLI + participant core as Dependabot Updater + participant feed as Package Feed + + ext->>ext: Read and parse `dependabot.yml` + ext->>ext: Write `job.yaml` + ext->>agent: Download dependabot-cli from github + ext->>+cli: Execute `dependabot update -f job.yaml -o update-scenario.yaml` + cli->>+core: Run update for `job.yaml` with proxy and dependabot-updater docker containers + core->>devops: Fetch source files from repository + core->>core: Discover dependencies + loop for each dependency + core->>feed: Fetch latest version + core->>core: Update dependency files + end + core-->>-cli: Report outputs + cli->>cli: Write outputs to `update-sceario.yaml` + cli-->>-ext: Update completed + + ext->>ext: Read and parse `update-sceario.yaml` + loop for each output + alt when output is "create_pull_request" + ext->>devops: Create pull request source branch + ext->>devops: Push commit to source branch + ext->>devops: Create pull request + ext->>devops: Set auto-approve + ext->>devops: Set auto-complete + end + alt when output is "update_pull_request" + ext->>devops: Push commit to pull request + ext->>devops: Update pull request description + ext->>devops: Set auto-approve + ext->>devops: Set auto-complete + end + alt when output is "close_pull_request" + ext->>devops: Create comment thread on pull request with close reason + ext->>devops: Abandon pull request + ext->>devops: Delete source branch + end + end + +``` diff --git a/docs/migrations/v1-to-v2.md b/docs/migrations/v1-to-v2.md new file mode 100644 index 00000000..35cfd7ef --- /dev/null +++ b/docs/migrations/v1-to-v2.md @@ -0,0 +1,64 @@ + +> [!WARNING] +> **:construction: Work in progress;** `dependabot@V2` is still under development and this document may change without notice up until general availability (GA). + +# Table of Contents +- [Summary of changes V1 → V2](#summary-of-changes-v1-v2) +- [Breaking changes V1 → V2](#breaking-changes-v1-v2) +- [Todo before general availability](#todo-before-general-availability) + +# Summary of changes V1 → V2 +V2 is a complete re-write of the Dependabot task; It aims to: + +- Resolve the [numerous private feed/registry authentication issues](https://github.com/tinglesoftware/dependabot-azure-devops/discussions/1317) that currently exist in V1; +- More closely align the update logic with the GitHub-hosted Dependabot service; + +The task now uses [Dependabot CLI](https://github.com/dependabot/cli) to perform dependency updates, which is the _[currently]_ recommended approach for running Dependabot. See [extension task architecture](../extension.md#architecture) for more details on the technical changes and impact to the update process. + +# Breaking changes V1 → V2 + +> [!WARNING] +> **It is strongly recommended that you complete (or abandon) all active Depedabot pull requests created in V1 before migrating to V2.** Due to changes in Dependabot dependency metadata, V2 pull requests are not compatible with V1 (and vice versa). Migrating to V2 before completing existing pull requests will lead to duplication of pull requests. + +### New pipeline agent requirements; "Go" must be installed +Dependabot CLI requires [Go](https://go.dev/doc/install) (1.22+) and [Docker](https://docs.docker.com/get-started/get-docker/) (with Linux containers). +If you use [Microsoft-hosted agents](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/hosted?view=azure-devops&tabs=yaml#software), we recommend using the [ubuntu-latest](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md) image, which meets all task requirements. +For self-hosted agents, you will need to install Go 1.22+. + +### Security-only updates and "fixed vulnerabilities" are not implemented (yet) +Using configuration `open-pull-requests-limit: 0` will cause a "not implemented" error. This is [current limitation of V2](../../README.md#unsupported-features-and-configurations). A solution is still under development and is expected to be resolved before general availability. +See: https://github.com/dependabot/cli/issues/360 for more technical details. + +### Task Input `updaterOptions` has been renamed to `experiments` +Renamed to match Dependabot Core/CLI terminology. The input value remains unchanged. See [configuring experiments](../../README.md#configuring-experiments) for more details. + +### Task Input `failOnException` has been removed +Due to the design of Dependabot CLI, the update process can no longer be interrupted once the update has started. Because of this, the update will now continue on error and summarise all error at the end of the update process. + +### Task Input `excludeRequirementsToUnlock` has been removed +This was a customisation/workaround specific to the V1 update script that can no longer be implemented with Dependabot CLI as it is not an official configuration option. + +### Task Input `dockerImageTag` has been removed +This is no longer required as the [custom] [Dependabot Updater image](../updater.md) is no longer used. + +### Task Input `extraEnvironmentVariables` has been removed +Due to the containerised design of Dependabot CLI, environment variables can no longer be passed from the task to the updater process. All Dependabot config must now set via `dependabot.yaml` or as task inputs. The following old environment variables have been converted to task inputs: + +| Environment Variable | New Task Input | +|--|--| +|DEPENDABOT_AUTHOR_EMAIL|authorEmail| +|DEPENDABOT_AUTHOR_NAME|authorName| + + +## Todo before general availability +Before removing the preview flag from V2 `task.json`, we need to: + - [x] Open an issue in Dependabot-CLI, enquire how security-advisories are expected to be provided **before** knowing the list of dependencies. (https://github.com/dependabot/cli/issues/360) + - [ ] Convert GitHub security advisory client in `vulnerabilities.rb` to TypeScript code + - [ ] Implement `security-advisories` config once the answer the above is known + - [x] Review `task.json`, add documentation for new V2 inputs + - [x] Update `\docs\extension.md` with V2 docs + - [x] Update `\extension\README.MD` with V2 docs + - [x] Update `\README.MD` with V2 docs + - [ ] Do a general code tidy-up pass (check all "TODO" comments) + - [ ] Add unit tests for V2 utils scripts + - [ ] Investigate https://zod.dev/ \ No newline at end of file diff --git a/docs/server.md b/docs/server.md index 5aead255..9bd18a49 100644 --- a/docs/server.md +++ b/docs/server.md @@ -4,10 +4,10 @@ - [Why should I use the server?](#why-should-i-use-the-server) - [Composition](#composition) - [Deployment](#deployment) - - [Single click deployment](#single-click-deployment) - - [Deployment Parameters](#deployment-parameters) - - [Deployment with CLI](#deployment-with-cli) - - [Service Hooks and Subscriptions](#service-hooks-and-subscriptions) + * [Single click deployment](#single-click-deployment) + * [Deployment Parameters](#deployment-parameters) + * [Deployment with CLI](#deployment-with-cli) + * [Service Hooks and Subscriptions](#service-hooks-and-subscriptions) - [Keeping updated](#keeping-updated) # Why should I use the server? @@ -59,10 +59,12 @@ The deployment exposes the following parameters that can be tuned to suit the se |githubToken|Access token for authenticating requests to GitHub. Required for vulnerability checks and to avoid rate limiting on free requests|No|<empty>| |imageTag|The image tag to use when pulling the docker containers. A tag also defines the version. You should avoid using `latest`. Example: `1.1.0`|No|<version-downloaded>| +> [!NOTE] > The template includes a User Assigned Managed Identity, which is used when performing Azure Resource Manager operations such as deletions. In the deployment it creates the role assignments that it needs. These role assignments are on the resource group that you deploy to. ## Deployment with CLI +> [!IMPORTANT] > Ensure the Azure CLI tools are installed and that you are logged in. For a one time deployment, it is similar to how you deploy other resources on Azure. diff --git a/docs/updater.md b/docs/updater.md index e27e042b..26ab4ba6 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -1,24 +1,27 @@ +> [!WARNING] +> **Deprecated;** Use of the Dependabot Updater image is no longer recommended since v2.0; The "updater" component is considered an internal to Dependabot and is not intended to be run directly by end-users. There are known limitations with this image, see [unsupported features and configuration](../README.md#unsupported-features-and-configurations) for more details. + # Table of Contents - [Running the updater](#running-the-updater) - - [Environment variables](#environment-variables) + * [Environment Variables](#environment-variables) - [Development guide](#development-guide) - - [Getting the development environment ready](#getting-the-development-environment-ready) - - [Building the Docker image](#building-the-docker-image) - - [Running your code changes](#running-your-code-changes) - - [Running the code linter](#running-the-code-linter) - - [Running the unit tests](#running-the-unit-tests) + * [Getting the development environment ready](#getting-the-development-environment-ready) + * [Building the Docker image](#building-the-docker-image) + * [Running your code changes](#running-your-code-changes) + * [Running the code linter](#running-the-code-linter) + * [Running the unit tests](#running-the-unit-tests) # Running the updater -First, you need to pull the docker image locally to your machine: +[Build](#building-the-docker-image) or pull the docker image: ```bash docker pull ghcr.io/tinglesoftware/dependabot-updater- ``` -Next create and run a container from the image. The full list of container options are detailed in [Environment variables](#environment-variables); at minimum the command should be: +Create and run a container based on the image. The full list of container options are detailed in [environment variables](#environment-variables); at minimum the command should be: ```bash docker run --rm -t \ @@ -38,7 +41,8 @@ docker run --rm -t \ ghcr.io/tinglesoftware/dependabot-updater- update_script ``` -An example, for Azure DevOps Services: +
+Example, for Azure DevOps Services ```bash docker run --rm -t \ @@ -55,7 +59,10 @@ docker run --rm -t \ ghcr.io/tinglesoftware/dependabot-updater-nuget update_script ``` -An example, for Azure DevOps Server: +
+ +
+Example, for Azure DevOps Server ```bash docker run --rm -t \ @@ -75,9 +82,11 @@ docker run --rm -t \ ghcr.io/tinglesoftware/dependabot-updater-nuget update_script ``` +
+ ## Environment Variables -To run the script, some environment variables are required. +The following environment variables are required when running the container. |Variable Name|Supported Command(s)|Description| |--|--|--| @@ -135,10 +144,12 @@ To run the script, some environment variables are required. ## Getting the development environment ready -First, ensure you have [Docker](https://docs.docker.com/engine/install/) and [Ruby](https://www.ruby-lang.org/en/documentation/installation/) installed. -On Linux, you'll need the the build essentials and Ruby development packages too; These are typically `build-essentials` and `ruby-dev`. +Install [Docker](https://docs.docker.com/engine/install/) and [Ruby](https://www.ruby-lang.org/en/documentation/installation/). + +> [!NOTE] +> If developing in Linux, you'll also need the the build essentials and Ruby development packages; These are typically `build-essentials` and `ruby-dev`. -Next, install project build tools with bundle: +Install the project build tools using Bundle: ```bash cd updater @@ -159,21 +170,23 @@ docker build \ . ``` -In some scenarios, you may want to set `BASE_VERSION` to a specific version instead of "latest". -See [updater/Dockerfile](../updater/Dockerfile) for a more detailed explanation. +> [!TIP] +> In some scenarios, you may want to set `BASE_VERSION` to a specific version instead of "latest". +> See [updater/Dockerfile](../updater/Dockerfile) for a more detailed explanation. ## Running your code changes -To test run your code changes, you'll first need to build the updater Docker image (see above), then run the updater Docker image in a container with all the required environment variables (see above). +To test run your code changes, you'll first need to [build the Docker image](#building-the-docker-image), then run the Docker image in a container with all the [required environment variables](#environment-variables). ## Running the code linter ```bash cd updater bundle exec rubocop -bundle exec rubocop -a # to automatically fix any correctable offenses ``` +> [!TIP] +> To automatically fix correctable linting issues, use `bundle exec rubocop -a` ## Running the unit tests ```bash diff --git a/extension/README.md b/extension/README.md index 6c7e9f8e..236d2d63 100644 --- a/extension/README.md +++ b/extension/README.md @@ -1,6 +1,6 @@ # Dependabot Azure DevOps Extension -This is the unofficial [dependabot](https://github.com/Dependabot/dependabot-core) extension for [Azure DevOps](https://azure.microsoft.com/en-gb/services/devops/). It will allow you to run Dependabot inside a build pipeline and is accessible [here in the Visual Studio marketplace](https://marketplace.visualstudio.com/items?itemName=tingle-software.dependabot). The extension first has to be installed before you can run it in your pipeline. +This is the unofficial [dependabot](https://github.com/Dependabot/dependabot-core) extension for [Azure DevOps](https://azure.microsoft.com/en-gb/services/devops/). It will allow you to run Dependabot inside a build pipeline. ## Usage @@ -9,7 +9,7 @@ Add a configuration file stored at `.azuredevops/dependabot.yml` or `.github/dep To use in a YAML pipeline: ```yaml -- task: dependabot@1 +- task: dependabot@2 ``` You can schedule the pipeline as is appropriate for your solution. @@ -32,23 +32,62 @@ pool: vmImage: 'ubuntu-latest' # requires macos or ubuntu (windows is not supported) steps: -- task: dependabot@1 +- task: dependabot@2 ``` -This task makes use of a docker image, which may take time to install. Subsequent dependabot tasks in a job will be faster after initially pulling the image using the first task. An alternative way to run your pipelines faster is by leveraging Docker caching in Azure Pipelines (See [#113](https://github.com/tinglesoftware/dependabot-azure-devops/issues/113#issuecomment-894771611)). +## Task Requirements + +The task uses [dependabot-cli](https://github.com/dependabot/cli), which requires [Go](https://go.dev/doc/install) (1.22+) and [Docker](https://docs.docker.com/get-started/get-docker/) (with Linux containers) be installed on the pipeline agent. +If you use [Microsoft-hosted agents](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/hosted?view=azure-devops&tabs=yaml#software), we recommend using the [ubuntu-latest](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md) image, which meets all task requirements. + +Dependabot uses Docker containers, which may take time to install if not already cached. Subsequent dependabot tasks in the same job will be faster after initially pulling the images. An alternative way to run your pipelines faster is by leveraging Docker caching in Azure Pipelines (See [#113](https://github.com/tinglesoftware/dependabot-azure-devops/issues/113#issuecomment-894771611)). ## Task Parameters +
+dependabot@V2 + |Input|Description| |--|--| +|skipPullRequests|**_Optional_**. Determines whether to skip creation and updating of pull requests. When set to `true` the logic to update the dependencies is executed but the actual Pull Requests are not created/updated. This is useful for debugging. Defaults to `false`.| +|abandonUnwantedPullRequests|**_Optional_**. Determines whether to abandon unwanted pull requests. Defaults to `false`.| +|commentPullRequests|**_Optional_**. Determines whether to comment on pull requests which an explanation of the reason for closing. Defaults to `false`.| +|setAutoComplete|**_Optional_**. Determines if the pull requests that dependabot creates should have auto complete set. When set to `true`, pull requests that pass all policies will be merged automatically. Defaults to `false`.| +|mergeStrategy|**_Optional_**. The merge strategy to use when auto complete is set. Learn more [here](https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/update?view=azure-devops-rest-6.0&tabs=HTTP#gitpullrequestmergestrategy). Defaults to `squash`.| +|autoCompleteIgnoreConfigIds|**_Optional_**. List of any policy configuration Id's which auto-complete should not wait for. Only applies to optional policies. Auto-complete always waits for required (blocking) policies.| +|autoApprove|**_Optional_**. Determines if the pull requests that dependabot creates should be automatically completed. When set to `true`, pull requests will be approved automatically. To use a different user for approval, supply `autoApproveUserToken` input. Defaults to `false`.| +|autoApproveUserToken|**_Optional_**. A personal access token for the user to automatically approve the created PR.| +|authorEmail|**_Optional_**. The email address to use for the change commit author. Can be used to associate the committer with an existing account, to provide a profile picture. Defaults to `noreply@github.com`.| +|authorName|**_Optional_**. The name to use as the git commit author of the pull requests. Defaults to `dependabot[bot]`.| +|securityAdvisoriesFile|**_Optional_**. The path to a JSON file containing additional security advisories to be included when performing package updates. See: [Configuring security advisories and known vulnerabilities](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-security-advisories-and-known-vulnerabilities).| +|azureDevOpsServiceConnection|**_Optional_**. A Service Connection to use for accessing Azure DevOps. Supply a value here to avoid using permissions for the Build Service either because you cannot change its permissions or because you prefer that the Pull Requests be done by a different user. When not provided, the current authentication scope is used.
See the [documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops) to know more about creating a Service Connections| +|azureDevOpsAccessToken|**_Optional_**. The Personal Access Token for accessing Azure DevOps. Supply a value here to avoid using permissions for the Build Service either because you cannot change its permissions or because you prefer that the Pull Requests be done by a different user. When not provided, the current authentication scope is used. In either case, be use the following permissions are granted:
- Code (Full)
- Pull Requests Threads (Read & Write).
See the [documentation](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#create-a-pat) to know more about creating a Personal Access Token.
Use this in place of `azureDevOpsServiceConnection` such as when it is not possible to create a service connection.| +|gitHubConnection|**_Optional_**. The GitHub service connection for authenticating requests against GitHub repositories. This is useful to avoid rate limiting errors. The token must include permissions to read public repositories. See the [GitHub docs](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) for more on Personal Access Tokens and [Azure DevOps docs](https://docs.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml#sep-github) for the GitHub service connection.| +|gitHubAccessToken|**_Optional_**. The raw GitHub PAT for authenticating requests against GitHub repositories. Use this in place of `gitHubConnection` such as when it is not possible to create a service connection.| +|storeDependencyList|**_Optional_**. Determines if the last know dependency list information should be stored in the parent DevOps project properties. If enabled, the authenticated user must have the "Project & Team (Write)" permission for the project. Enabling this option improves performance when doing security-only updates. Defaults to `false`.| +|targetRepositoryName|**_Optional_**. The name of the repository to target for processing. If this value is not supplied then the Build Repository Name is used. Supplying this value allows creation of a single pipeline that runs Dependabot against multiple repositories by running a `dependabot` task for each repository to update.| +|targetUpdateIds|**_Optional_**. A semicolon (`;`) delimited list of update identifiers run. Index are zero-based and in the order written in the configuration file. When not present, all the updates are run. This is meant to be used in scenarios where you want to run updates a different times from the same configuration file given you cannot schedule them independently in the pipeline.| +|experiments|**_Optional_**. Comma separated list of Dependabot experiments; available options depend on the ecosystem. Example: `tidy=true,vendor=true,goprivate=*`. See: [Configuring experiments](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-experiments)| + +
+ +
+dependabot@V1 (Deprecated) + +|Input|Description| +|--|--| +|useUpdateScriptvNext|**_Optional_**. Determines if the task should use the new "vNext" update script based on Dependabot Updater (true), or the original update script based on `dry-run.rb` (false). Defaults to `false`. For more information, see: [PR #1186](https://github.com/tinglesoftware/dependabot-azure-devops/pull/1186).| |failOnException|**_Optional_**. Determines if the execution should fail when an exception occurs. Defaults to `true`.| -|updaterOptions|**_Optional_**. Comma separated list of updater options; available options depend on the ecosystem. Example: `goprivate=true,kubernetes_updates=true`.| +|updaterOptions|**_Optional_**. Comma separated list of updater options; available options depend on the ecosystem. Example: `tidy=true,vendor=true,goprivate=*`. See: [Configuring experiments](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-experiments)| |setAutoComplete|**_Optional_**. Determines if the pull requests that dependabot creates should have auto complete set. When set to `true`, pull requests that pass all policies will be merged automatically. Defaults to `false`.| |mergeStrategy|**_Optional_**. The merge strategy to use when auto complete is set. Learn more [here](https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/update?view=azure-devops-rest-6.0&tabs=HTTP#gitpullrequestmergestrategy). Defaults to `squash`.| +|autoCompleteIgnoreConfigIds|**_Optional_**. List of any policy configuration Id's which auto-complete should not wait for. Only applies to optional policies. Auto-complete always waits for required (blocking) policies.| |autoApprove|**_Optional_**. Determines if the pull requests that dependabot creates should be automatically completed. When set to `true`, pull requests will be approved automatically. To use a different user for approval, supply `autoApproveUserToken` input. Defaults to `false`.| |autoApproveUserToken|**_Optional_**. A personal access token for the user to automatically approve the created PR.| |skipPullRequests|**_Optional_**. Determines whether to skip creation and updating of pull requests. When set to `true` the logic to update the dependencies is executed but the actual Pull Requests are not created/updated. This is useful for debugging. Defaults to `false`.| |abandonUnwantedPullRequests|**_Optional_**. Determines whether to abandon unwanted pull requests. Defaults to `false`.| +|commentPullRequests|**_Optional_**. Determines whether to comment on pull requests which an explanation of the reason for closing. Defaults to `false`.| +|securityAdvisoriesFile|**_Optional_**. The path to a JSON file containing additional security advisories to be included when performing package updates. See: [Configuring security advisories and known vulnerabilities](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-security-advisories-and-known-vulnerabilities).| |gitHubConnection|**_Optional_**. The GitHub service connection for authenticating requests against GitHub repositories. This is useful to avoid rate limiting errors. The token must include permissions to read public repositories. See the [GitHub docs](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) for more on Personal Access Tokens and [Azure DevOps docs](https://docs.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml#sep-github) for the GitHub service connection.| |gitHubAccessToken|**_Optional_**. The raw GitHub PAT for authenticating requests against GitHub repositories. Use this in place of `gitHubConnection` such as when it is not possible to create a service connection.| |azureDevOpsServiceConnection|**_Optional_**. A Service Connection to use for accessing Azure DevOps. Supply a value here to avoid using permissions for the Build Service either because you cannot change its permissions or because you prefer that the Pull Requests be done by a different user. When not provided, the current authentication scope is used.
See the [documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops) to know more about creating a Service Connections| @@ -59,32 +98,12 @@ This task makes use of a docker image, which may take time to install. Subsequen |dockerImageTag|**_Optional_**. The image tag to use when pulling the docker container used by the task. A tag also defines the version. By default, the task decides which tag/version to use. This can be the latest or most stable version. When not provided, the value is inferred from the current task version| |extraEnvironmentVariables|**_Optional_**. A semicolon (`;`) delimited list of environment variables that are sent to the docker container. See possible use case [here](https://github.com/tinglesoftware/dependabot-azure-devops/issues/138)| -## Advanced +
-In some situations, such as when getting the latest bits for testing, you might want to override the docker image tag that is pulled. Even though doing so is discouraged you can declare a global variable, for example: - -```yaml -trigger: none # Disable CI trigger - -schedules: -- cron: '0 2 * * *' # daily at 2am UTC - always: true # run even when there are no code changes - branches: - include: - - master - batch: true - displayName: Daily - -# variables declared below can be put in one or more Variable Groups for sharing across pipelines -variables: - DEPENDABOT_ALLOW_CONDITIONS: '[{\"dependency-name\":"django*",\"dependency-type\":\"direct\"}]' # packages allowed to be updated - DEPENDABOT_IGNORE_CONDITIONS: '[{\"dependency-name\":"@types/*"}]' # packages ignored to be updated - -pool: - vmImage: 'ubuntu-latest' # requires macos or ubuntu (windows is not supported) - -steps: -- task: dependabot@1 -``` +## Advanced -Check the logs for the image that is pulled. +- [Configuring private feeds and registries](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-private-feeds-and-registries) +- [Configuring security advisories and known vulnerabilities](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-security-advisories-and-known-vulnerabilities) +- [Configuring experiments](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-experiments) +- [Unsupported features and configurations](https://github.com/tinglesoftware/dependabot-azure-devops/#unsupported-features-and-configurations) +- [Task migration guide for V1 → V2](https://github.com/tinglesoftware/dependabot-azure-devops/blob/main/docs/migrations/v1-to-v2.md) diff --git a/extension/overrides.local.json b/extension/overrides.local.json index 8063d93a..f9d0c89a 100644 --- a/extension/overrides.local.json +++ b/extension/overrides.local.json @@ -1,5 +1,5 @@ { "id": "dependabot-local", - "version": "1.0.0.0", + "version": "2.0.0.0", "name": "Dependabot (Local)" } diff --git a/extension/package-lock.json b/extension/package-lock.json index abe19111..280e501e 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,15 +1,16 @@ { "name": "dependabot-azure-devops", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dependabot-azure-devops", - "version": "1.0.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "axios": "1.7.7", + "azure-devops-node-api": "^14.0.2", "azure-pipelines-task-lib": "4.17.2", "js-yaml": "4.1.0" }, @@ -1312,6 +1313,18 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/azure-devops-node-api": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-14.0.2.tgz", + "integrity": "sha512-TwjAEnWnOSZ2oypkDyqppgvJw43qArEfPiJtEWLL3NBgdvAuOuB0xgFz/Eiz4H6Dk0Yv52wCodZxtZvAMhJXwQ==", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^2.0.1" + }, + "engines": { + "node": ">= 16.0.0" + } + }, "node_modules/azure-pipelines-task-lib": { "version": "4.17.2", "resolved": "https://registry.npmjs.org/azure-pipelines-task-lib/-/azure-pipelines-task-lib-4.17.2.tgz", @@ -1528,6 +1541,24 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1762,6 +1793,22 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1770,6 +1817,15 @@ "node": ">=0.4.0" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -1846,6 +1902,25 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -2075,6 +2150,24 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -2136,6 +2229,17 @@ "node": ">=4" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2151,6 +2255,39 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2979,6 +3116,11 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3166,6 +3308,11 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "node_modules/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz", @@ -3232,6 +3379,17 @@ "node": ">=8" } }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3464,6 +3622,20 @@ "teleport": ">=0.2.0" } }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3552,6 +3724,22 @@ "semver": "bin/semver" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3589,6 +3777,23 @@ "node": ">=4" } }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -3893,6 +4098,14 @@ } } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -3914,6 +4127,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-rest-client": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.0.2.tgz", + "integrity": "sha512-rmAQM2gZw/PQpK5+5aSs+I6ZBv4PFC2BT1o+0ADS1SgSejA+14EmbI2Lt8uXwkX7oeOMkwFmg0pHKwe8D9IT5A==", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "^6.10.3", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + }, + "engines": { + "node": ">= 16.0.0" + } + }, "node_modules/typescript": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", @@ -3928,6 +4156,11 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==" + }, "node_modules/undici-types": { "version": "6.19.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.6.tgz", diff --git a/extension/package.json b/extension/package.json index 41282b62..d81bb953 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,14 +1,16 @@ { "name": "dependabot-azure-devops", - "version": "1.0.0", + "version": "2.0.0", "description": "Dependabot Azure DevOps task", "main": "''", "scripts": { - "postdependencies": "cp -r node_modules tasks/dependabot/dependabotV1/node_modules", + "postdependencies": "cp -r node_modules tasks/dependabot/dependabotV1/node_modules && cp -r node_modules tasks/dependabot/dependabotV2/node_modules", "build": "tsc -p .", - "package": "npx tfx-cli extension create --json5", - "start": "node tasks/dependabot/dependabotV1/index.js", - "test": "jest" + "start": "npm run start:V2", + "start:V1": "node tasks/dependabot/dependabotV1/index.js", + "start:V2": "node tasks/dependabot/dependabotV2/index.js", + "test": "jest", + "package": "npx tfx-cli extension create --json5" }, "repository": { "type": "git", @@ -27,6 +29,7 @@ "homepage": "https://github.com/tinglesoftware/dependabot-azure-devops#readme", "dependencies": { "axios": "1.7.7", + "azure-devops-node-api": "^14.0.2", "azure-pipelines-task-lib": "4.17.2", "js-yaml": "4.1.0" }, diff --git a/extension/tasks/dependabot/dependabotV1/task.json b/extension/tasks/dependabot/dependabotV1/task.json index b7368afa..82a4bd51 100644 --- a/extension/tasks/dependabot/dependabotV1/task.json +++ b/extension/tasks/dependabot/dependabotV1/task.json @@ -3,7 +3,7 @@ "id": "d98b873d-cf18-41eb-8ff5-234f14697896", "name": "dependabot", "friendlyName": "Dependabot", - "description": "Automatically update dependencies and vulnerabilities in your code", + "description": "Automatically update dependencies and vulnerabilities in your code using [Dependabot Updater](https://github.com/dependabot/dependabot-core/tree/main/updater)", "helpMarkDown": "For help please visit https://github.com/tinglesoftware/dependabot-azure-devops/issues", "helpUrl": "https://github.com/tinglesoftware/dependabot-azure-devops/issues", "releaseNotes": "https://github.com/tinglesoftware/dependabot-azure-devops/releases", @@ -17,6 +17,8 @@ "Minor": 0, "Patch": 0 }, + "deprecated": true, + "deprecationMessage": "This task version is deprecated and is no longer maintained. Please upgrade to the latest version to continue receiving fixes and features. More details: https://github.com/tinglesoftware/dependabot-azure-devops/discussions/1317.", "instanceNameFormat": "Dependabot", "minimumAgentVersion": "3.232.1", "groups": [ diff --git a/extension/tasks/dependabot/dependabotV1/utils/getSharedVariables.ts b/extension/tasks/dependabot/dependabotV1/utils/getSharedVariables.ts index 87a90ab6..56383d79 100644 --- a/extension/tasks/dependabot/dependabotV1/utils/getSharedVariables.ts +++ b/extension/tasks/dependabot/dependabotV1/utils/getSharedVariables.ts @@ -122,7 +122,7 @@ export default function getSharedVariables(): ISharedVariables { let excludeRequirementsToUnlock = tl.getInput('excludeRequirementsToUnlock') || ''; let updaterOptions = tl.getInput('updaterOptions'); - let debug: boolean = tl.getVariable('System.Debug')?.localeCompare('true') === 0; + let debug: boolean = tl.getVariable('System.Debug')?.match(/true/i) ? true : false; // Get the target identifiers let targetUpdateIds = tl.getDelimitedInput('targetUpdateIds', ';', false).map(Number); diff --git a/extension/tasks/dependabot/dependabotV2/icon.png b/extension/tasks/dependabot/dependabotV2/icon.png new file mode 100644 index 00000000..ffa0fe7c Binary files /dev/null and b/extension/tasks/dependabot/dependabotV2/icon.png differ diff --git a/extension/tasks/dependabot/dependabotV2/index.ts b/extension/tasks/dependabot/dependabotV2/index.ts new file mode 100644 index 00000000..b899edf7 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/index.ts @@ -0,0 +1,132 @@ +import { which, setResult, TaskResult } from "azure-pipelines-task-lib/task" +import { debug, warning, error } from "azure-pipelines-task-lib/task" +import { DependabotCli } from './utils/dependabot-cli/DependabotCli'; +import { AzureDevOpsWebApiClient } from "./utils/azure-devops/AzureDevOpsWebApiClient"; +import { IDependabotUpdate } from "./utils/dependabot/interfaces/IDependabotConfig"; +import { DependabotOutputProcessor, parseProjectDependencyListProperty, parsePullRequestProperties } from "./utils/dependabot-cli/DependabotOutputProcessor"; +import { DependabotJobBuilder } from "./utils/dependabot-cli/DependabotJobBuilder"; +import parseDependabotConfigFile from './utils/dependabot/parseConfigFile'; +import parseTaskInputConfiguration from './utils/getSharedVariables'; + +async function run() { + let dependabot: DependabotCli = undefined; + let failedJobs: number = 0; + try { + + // Check if required tools are installed + debug('Checking for `docker` install...'); + which('docker', true); + debug('Checking for `go` install...'); + which('go', true); + + // Parse task input configuration + const taskInputs = parseTaskInputConfiguration(); + if (!taskInputs) { + throw new Error('Failed to parse task input configuration'); + } + + // Parse dependabot.yaml configuration file + const dependabotConfig = await parseDependabotConfigFile(taskInputs); + if (!dependabotConfig) { + throw new Error('Failed to parse dependabot.yaml configuration file from the target repository'); + } + + // Initialise the DevOps API clients + // There are two clients; one for authoring pull requests and one for auto-approving pull requests (if configured) + const prAuthorClient = new AzureDevOpsWebApiClient(taskInputs.organizationUrl.toString(), taskInputs.systemAccessToken); + const prApproverClient = taskInputs.autoApprove ? new AzureDevOpsWebApiClient(taskInputs.organizationUrl.toString(), taskInputs.autoApproveUserToken || taskInputs.systemAccessToken) : null; + + // Fetch the active pull requests created by the author user + const prAuthorActivePullRequests = await prAuthorClient.getActivePullRequestProperties( + taskInputs.project, taskInputs.repository, await prAuthorClient.getUserId() + ); + + // Initialise the Dependabot updater + dependabot = new DependabotCli( + DependabotCli.CLI_IMAGE_LATEST, // TODO: Add config for this? + new DependabotOutputProcessor(taskInputs, prAuthorClient, prApproverClient, prAuthorActivePullRequests), + taskInputs.debug + ); + + const dependabotUpdaterOptions = { + collectorImage: undefined, // TODO: Add config for this? + proxyImage: undefined, // TODO: Add config for this? + updaterImage: undefined // TODO: Add config for this? + }; + + // If update identifiers are specified, select them; otherwise handle all + let updates: IDependabotUpdate[] = []; + const targetIds = taskInputs.targetUpdateIds; + if (targetIds && targetIds.length > 0) { + for (const id of targetIds) { + updates.push(dependabotConfig.updates[id]); + } + } else { + updates = dependabotConfig.updates; + } + + // Loop through the [targeted] update blocks in dependabot.yaml and perform updates + for (const update of updates) { + const updateId = updates.indexOf(update).toString(); + + // Parse the last dependency list snapshot (if any) from the project properties. + // This is required when doing a security-only update as dependabot requires the list of vulnerable dependencies to be updated. + // Automatic discovery of vulnerable dependencies during a security-only update is not currently supported by dependabot-updater. + const dependencyList = parseProjectDependencyListProperty( + await prAuthorClient.getProjectProperties(taskInputs.project), + taskInputs.repository, + update["package-ecosystem"] + ); + + // Parse the Dependabot metadata for the existing pull requests that are related to this update + // Dependabot will use this to determine if we need to create new pull requests or update/close existing ones + const existingPullRequests = parsePullRequestProperties(prAuthorActivePullRequests, update["package-ecosystem"]); + const existingPullRequestDependencies = Object.entries(existingPullRequests).map(([id, deps]) => deps); + + // Run an update job for "all dependencies"; this will create new pull requests for dependencies that need updating + const allDependenciesJob = DependabotJobBuilder.newUpdateAllJob(taskInputs, updateId, update, dependabotConfig.registries, dependencyList['dependencies'], existingPullRequestDependencies); + const allDependenciesUpdateOutputs = await dependabot.update(allDependenciesJob, dependabotUpdaterOptions); + if (!allDependenciesUpdateOutputs || allDependenciesUpdateOutputs.filter(u => !u.success).length > 0) { + allDependenciesUpdateOutputs.filter(u => !u.success).forEach(u => exception(u.error)); + failedJobs++; + } + + // Run an update job for each existing pull request; this will resolve merge conflicts and close pull requests that are no longer needed + if (!taskInputs.skipPullRequests) { + for (const pullRequestId in existingPullRequests) { + const updatePullRequestJob = DependabotJobBuilder.newUpdatePullRequestJob(taskInputs, pullRequestId, update, dependabotConfig.registries, existingPullRequestDependencies, existingPullRequests[pullRequestId]); + const updatePullRequestOutputs = await dependabot.update(updatePullRequestJob, dependabotUpdaterOptions); + if (!updatePullRequestOutputs || updatePullRequestOutputs.filter(u => !u.success).length > 0) { + updatePullRequestOutputs.filter(u => !u.success).forEach(u => exception(u.error)); + failedJobs++; + } + } + } else if (existingPullRequests.keys.length > 0) { + warning(`Skipping update of existing pull requests as 'skipPullRequests' is set to 'true'`); + } + + } + + setResult( + failedJobs ? TaskResult.Failed : TaskResult.Succeeded, + failedJobs ? `${failedJobs} update job(s) failed, check logs for more information` : `All update jobs completed successfully` + ); + + } + catch (e) { + setResult(TaskResult.Failed, e?.message); + exception(e); + } + finally { + dependabot?.cleanup(); + } +} + +function exception(e: Error) { + if (e) { + error(`An unhandled exception occurred: ${e}`); + console.error(e); + } +} + +run(); diff --git a/extension/tasks/dependabot/dependabotV2/task.json b/extension/tasks/dependabot/dependabotV2/task.json new file mode 100644 index 00000000..978e213a --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/task.json @@ -0,0 +1,239 @@ +{ + "$schema": "https://raw.githubusercontent.com/Microsoft/azure-pipelines-task-lib/master/tasks.schema.json", + "id": "d98b873d-cf18-41eb-8ff5-234f14697896", + "name": "dependabot", + "friendlyName": "Dependabot", + "description": "Automatically update dependencies and vulnerabilities in your code using [Dependabot CLI](https://github.com/dependabot/cli)", + "helpMarkDown": "For help please visit https://github.com/tinglesoftware/dependabot-azure-devops/issues", + "helpUrl": "https://github.com/tinglesoftware/dependabot-azure-devops/issues", + "releaseNotes": "https://github.com/tinglesoftware/dependabot-azure-devops/releases", + "category": "Utility", + "visibility": ["Build", "Release"], + "runsOn": ["Agent", "DeploymentGroup"], + "author": "Tingle Software", + "demands": [], + "version": { + "Major": 2, + "Minor": 0, + "Patch": 0 + }, + "preview": true, + "instanceNameFormat": "Dependabot", + "minimumAgentVersion": "3.232.1", + "groups": [ + { + "name": "pull_requests", + "displayName": "Pull request options", + "isExpanded": true + }, + { + "name": "security_updates", + "displayName": "Security advisories and vulnerabilities", + "isExpanded": false + }, + { + "name": "devops", + "displayName": "Azure DevOps authentication", + "isExpanded": false + }, + { + "name": "github", + "displayName": "GitHub authentication", + "isExpanded": false + }, + { + "name": "advanced", + "displayName": "Advanced", + "isExpanded": false + } + ], + "inputs": [ + + { + "name": "skipPullRequests", + "type": "boolean", + "groupName": "pull_requests", + "label": "Skip creation and updating of pull requests.", + "defaultValue": false, + "required": false, + "helpMarkDown": "When set to `true` the logic to update the dependencies is executed but the actual Pull Requests are not created/updated. Defaults to `false`." + }, + { + "name": "abandonUnwantedPullRequests", + "type": "boolean", + "groupName": "pull_requests", + "label": "Abandon unwanted pull requests.", + "defaultValue": false, + "required": false, + "helpMarkDown": "When set to `true` pull requests that are no longer needed are closed at the tail end of the execution. Defaults to `false`." + }, + { + "name": "commentPullRequests", + "type": "boolean", + "groupName": "pull_requests", + "label": "Comment on abandoned pull requests with close reason.", + "defaultValue": false, + "required": false, + "helpMarkDown": "When set to `true` a comment will be added to abandoned pull requests explanating why it was closed. Defaults to `false`.", + "visibleRule": "abandonUnwantedPullRequests=true" + }, + { + "name": "setAutoComplete", + "type": "boolean", + "groupName": "pull_requests", + "label": "Auto-complete pull requests when all policies pass", + "defaultValue": false, + "required": false, + "helpMarkDown": "When set to `true`, pull requests that pass all policies will be merged automatically. Defaults to `false`." + }, + { + "name": "mergeStrategy", + "type": "pickList", + "groupName": "pull_requests", + "label": "Auto-complete merge Strategy", + "defaultValue": "squash", + "required": true, + "helpMarkDown": "The merge strategy to use. Learn more [here](https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/update?view=azure-devops-rest-5.1&tabs=HTTP#gitpullrequestmergestrategy).", + "options": { + "noFastForward": "No fast forward", + "rebase": "Rebase", + "rebaseMerge": "Rebase merge", + "squash": "Squash" + }, + "visibleRule": "setAutoComplete=true" + }, + { + "name": "autoCompleteIgnoreConfigIds", + "type": "string", + "groupName": "pull_requests", + "label": "Semicolon delimited list of any policy configuration IDs which auto-complete should not wait for.", + "defaultValue": "", + "required": false, + "helpMarkDown": "A semicolon (`;`) delimited list of any policy configuration IDs which auto-complete should not wait for. Only applies to optional policies (isBlocking == false). Auto-complete always waits for required policies (isBlocking == true).", + "visibleRule": "setAutoComplete=true" + }, + { + "name": "autoApprove", + "type": "boolean", + "groupName": "pull_requests", + "label": "Auto-approve pull requests", + "defaultValue": false, + "required": false, + "helpMarkDown": "When set to `true`, pull requests will automatically be approved by the specified user. Defaults to `false`." + }, + { + "name": "autoApproveUserToken", + "type": "string", + "groupName": "pull_requests", + "label": "A personal access token of the user that should approve the PR.", + "defaultValue": "", + "required": false, + "helpMarkDown": "A personal access token of the user of that shall be used to approve the created PR automatically. If the same user that creates the PR should approve, this can be left empty. This won't work with if the Build Service with the build service account!", + "visibleRule": "autoApprove=true" + }, + { + "name": "authorEmail", + "type": "string", + "groupName": "pull_requests", + "label": "Git commit uthor email address", + "defaultValue": "", + "required": false, + "helpMarkDown": "The email address to use for the change commit author. Can be used to associate the committer with an existing account, to provide a profile picture. Defaults to `noreply@github.com`." + }, + { + "name": "authorName", + "type": "string", + "groupName": "pull_requests", + "label": "Git commit author name", + "defaultValue": "", + "required": false, + "helpMarkDown": "The name to use as the git commit author of the pull requests. Defaults to `dependabot[bot]`." + }, + + { + "name": "securityAdvisoriesFile", + "type": "string", + "label": "Path for the file containing security advisories in JSON format.", + "groupName": "security_updates", + "helpMarkDown": "The file containing security advisories.", + "required": false + }, + + { + "name": "azureDevOpsServiceConnection", + "type": "connectedService:Externaltfs", + "groupName": "devops", + "label": "Azure DevOps Service Connection to use.", + "required": false, + "helpMarkDown": "Specify a service connection to use, if you want to use a different service principal than the default to create your PRs." + }, + { + "name": "azureDevOpsAccessToken", + "type": "string", + "groupName": "devops", + "label": "Azure DevOps Personal Access Token.", + "required": false, + "helpMarkDown": "The Personal Access Token for accessing Azure DevOps repositories. Supply a value here to avoid using permissions for the Build Service either because you cannot change its permissions or because you prefer that the Pull Requests be done by a different user. Use this in place of `azureDevOpsServiceConnection` such as when it is not possible to create a service connection." + }, + + { + "name": "gitHubConnection", + "type": "connectedService:github:OAuth,PersonalAccessToken,InstallationToken,Token", + "groupName": "github", + "label": "GitHub connection (OAuth or PAT)", + "defaultValue": "", + "required": false, + "helpMarkDown": "Specify the name of the GitHub service connection to use to connect to the GitHub repositories. The connection must be based on a GitHub user's OAuth or a GitHub personal access token. Learn more about service connections [here](https://aka.ms/AA3am5s)." + }, + { + "name": "gitHubAccessToken", + "type": "string", + "groupName": "github", + "label": "GitHub Personal Access Token.", + "defaultValue": "", + "required": false, + "helpMarkDown": "The raw Personal Access Token for accessing GitHub repositories. Use this in place of `gitHubConnection` such as when it is not possible to create a service connection." + }, + + { + "name": "storeDependencyList", + "type": "boolean", + "groupName": "advanced", + "label": "Monitor the discovered dependencies", + "defaultValue": false, + "required": false, + "helpMarkDown": "Determines if the last know dependency list information should be stored in the parent DevOps project properties. If enabled, the authenticated user must have the `Project & Team (Write)` permission for the project. Enabling this option improves performance when doing security-only updates. Defaults to `false`." + }, + { + "name": "targetRepositoryName", + "type": "string", + "groupName": "advanced", + "label": "Target Repository Name", + "required": false, + "helpMarkDown": "The name of the repository to target for processing. If this value is not supplied then the Build Repository Name is used. Supplying this value allows creation of a single pipeline that runs Dependabot against multiple repositories." + }, + { + "name": "targetUpdateIds", + "type": "string", + "groupName": "advanced", + "label": "Semicolon delimited list of update identifiers to run.", + "defaultValue": "", + "required": false, + "helpMarkDown": "A semicolon (`;`) delimited list of update identifiers run. Index are zero-based and in the order written in the configuration file. When not present, all the updates are run. This is meant to be used in scenarios where you want to run updates a different times from the same configuration file given you cannot schedule them independently in the pipeline." + }, + { + "name": "experiments", + "type": "string", + "groupName": "advanced", + "label": "Dependabot updater experiments", + "required": false, + "helpMarkDown": "Comma-seperated list of key/value pairs representing the enabled Dependabot experiments e.g. `experiments: 'tidy=true,vendor=true,goprivate=*'`. Available options vary depending on the package ecosystem. See [configuring experiments](https://github.com/tinglesoftware/dependabot-azure-devops/#configuring-experiments) for more details." + } + ], + "dataSourceBindings": [], + "execution": { + "Node20_1": { + "target": "index.js" + } + } +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/AzureDevOpsWebApiClient.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/AzureDevOpsWebApiClient.ts new file mode 100644 index 00000000..a7288517 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/AzureDevOpsWebApiClient.ts @@ -0,0 +1,556 @@ +import { debug, warning, error } from "azure-pipelines-task-lib/task" +import { WebApi, getPersonalAccessTokenHandler } from "azure-devops-node-api"; +import { CommentThreadStatus, CommentType, IdentityRefWithVote, ItemContentType, PullRequestAsyncStatus, PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces"; +import { IPullRequestProperties } from "./interfaces/IPullRequestProperties"; +import { IPullRequest } from "./interfaces/IPullRequest"; +import { IFileChange } from "./interfaces/IFileChange"; +import { resolveAzureDevOpsIdentities } from "./resolveAzureDevOpsIdentities"; + +/** + * Wrapper for DevOps WebApi client with helper methods for easier management of dependabot pull requests + */ +export class AzureDevOpsWebApiClient { + + private readonly organisationApiUrl: string; + private readonly accessToken: string; + private readonly connection: WebApi; + private cachedUserIds: Record; + + constructor(organisationApiUrl: string, accessToken: string) { + this.organisationApiUrl = organisationApiUrl; + this.accessToken = accessToken; + this.connection = new WebApi( + organisationApiUrl, + getPersonalAccessTokenHandler(accessToken) + ); + this.cachedUserIds = {}; + } + + /** + * Get the identity of a user by email address. If no email is provided, the identity of the authenticated user is returned. + * @param email + * @returns + */ + public async getUserId(email?: string): Promise { + + // If no email is provided, resolve to the authenticated user + if (!email) { + this.cachedUserIds[this.accessToken] ||= ((await this.connection.connect())?.authenticatedUser?.id || ""); + return this.cachedUserIds[this.accessToken]; + } + + // Otherwise, do a cached identity lookup of the supplied email address + // TODO: When azure-devops-node-api supports Graph API, use that instead of the REST API + else if (!this.cachedUserIds[email]) { + const identities = await resolveAzureDevOpsIdentities(new URL(this.organisationApiUrl), [email]); + identities.forEach(i => this.cachedUserIds[i.input] ||= i.id); + } + + return this.cachedUserIds[email]; + } + + /** + * Get the default branch for a repository + * @param project + * @param repository + * @returns + */ + public async getDefaultBranch(project: string, repository: string): Promise { + try { + const git = await this.connection.getGitApi(); + const repo = await git.getRepository(repository, project); + if (!repo) { + throw new Error(`Repository '${project}/${repository}' not found`); + } + + return repo.defaultBranch; + } + catch (e) { + error(`Failed to get default branch for '${project}/${repository}': ${e}`); + console.error(e); + return undefined; + } + } + + /** + * Get the properties for all active pull request created by the supplied user + * @param project + * @param repository + * @param creator + * @returns + */ + public async getActivePullRequestProperties(project: string, repository: string, creator: string): Promise { + console.info(`Fetching active pull request properties in '${project}/${repository}' for user id '${creator}'...`); + try { + const git = await this.connection.getGitApi(); + const pullRequests = await git.getPullRequests( + repository, + { + creatorId: isGuid(creator) ? creator : await this.getUserId(creator), + status: PullRequestStatus.Active + }, + project + ); + + return await Promise.all( + pullRequests?.map(async pr => { + const properties = (await git.getPullRequestProperties(repository, pr.pullRequestId, project))?.value; + return { + id: pr.pullRequestId, + properties: Object.keys(properties)?.map(key => { + return { + name: key, + value: properties[key].$value + }; + }) || [] + }; + }) + ); + } + catch (e) { + error(`Failed to list active pull request properties: ${e}`); + console.error(e); + return []; + } + } + + /** + * Create a new pull request + * @param pr + * @returns + */ + public async createPullRequest(pr: IPullRequest): Promise { + console.info(`Creating pull request '${pr.title}'...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Create the source branch and commit the file changes + console.info(` - Pushing ${pr.changes.length} change(s) to branch '${pr.source.branch}'...`); + const push = await git.createPush( + { + refUpdates: [ + { + name: `refs/heads/${pr.source.branch}`, + oldObjectId: pr.source.commit + } + ], + commits: [ + { + comment: pr.commitMessage, + author: pr.author, + changes: pr.changes.map(change => { + return { + changeType: change.changeType, + item: { + path: normalizeDevOpsPath(change.path) + }, + newContent: { + content: Buffer.from(change.content, change.encoding).toString('base64'), + contentType: ItemContentType.Base64Encoded + } + }; + }) + } + ] + }, + pr.repository, + pr.project + ); + + // Build the list of the pull request reviewers + // NOTE: Azure DevOps does not have a concept of assignees, only reviewers. + // We treat assignees as required reviewers and all other reviewers as optional. + const allReviewers: IdentityRefWithVote[] = []; + if (pr.assignees?.length > 0) { + for (const assignee of pr.assignees) { + const identityId = isGuid(assignee) ? assignee : await this.getUserId(assignee); + if (identityId) { + allReviewers.push({ + id: identityId, + isRequired: true, + isFlagged: true, + }); + } + else { + warning(` - Unable to resolve assignee identity '${assignee}'`); + } + } + } + if (pr.reviewers?.length > 0) { + for (const reviewer of pr.reviewers) { + const identityId = isGuid(reviewer) ? reviewer : await this.getUserId(reviewer); + if (identityId) { + allReviewers.push({ + id: identityId, + }); + } + else { + warning(` - Unable to resolve reviewer identity '${reviewer}'`); + } + } + } + + // Create the pull request + console.info(` - Creating pull request to merge '${pr.source.branch}' into '${pr.target.branch}'...`); + const pullRequest = await git.createPullRequest( + { + sourceRefName: `refs/heads/${pr.source.branch}`, + targetRefName: `refs/heads/${pr.target.branch}`, + title: pr.title, + description: pr.description, + reviewers: allReviewers, + workItemRefs: pr.workItems?.map(id => { return { id: id }; }), + labels: pr.labels?.map(label => { return { name: label }; }), + isDraft: false // TODO: Add config for this? + }, + pr.repository, + pr.project, + true + ); + + // Add the pull request properties + if (pr.properties?.length > 0) { + console.info(` - Adding dependency metadata to pull request properties...`); + await git.updatePullRequestProperties( + null, + pr.properties.map(property => { + return { + op: "add", + path: "/" + property.name, + value: property.value + }; + }), + pr.repository, + pullRequest.pullRequestId, + pr.project + ); + } + + // TODO: Upload the pull request description as a 'changes.md' file attachment? + // This might be a way to work around the 4000 character limit for PR descriptions, but needs more investigation. + // https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-attachments/create?view=azure-devops-rest-7.1 + + // Set the pull request auto-complete status + if (pr.autoComplete) { + console.info(` - Setting auto-complete...`); + await git.updatePullRequest( + { + autoCompleteSetBy: { + id: userId + }, + completionOptions: { + autoCompleteIgnoreConfigIds: pr.autoComplete.ignorePolicyConfigIds, + deleteSourceBranch: true, + mergeCommitMessage: mergeCommitMessage(pullRequest.pullRequestId, pr.title, pr.description), + mergeStrategy: pr.autoComplete.mergeStrategy, + transitionWorkItems: false, + } + }, + pr.repository, + pullRequest.pullRequestId, + pr.project + ); + } + + console.info(` - Pull request #${pullRequest.pullRequestId} was created successfully.`); + return pullRequest.pullRequestId; + } + catch (e) { + error(`Failed to create pull request: ${e}`); + console.error(e); + return null; + } + } + + /** + * Update a pull request + * @param options + * @returns + */ + public async updatePullRequest(options: { + project: string, + repository: string, + pullRequestId: number, + changes: IFileChange[], + skipIfCommitsFromUsersOtherThan?: string, + skipIfNoConflicts?: boolean + }): Promise { + console.info(`Updating pull request #${options.pullRequestId}...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Get the pull request details + const pullRequest = await git.getPullRequest(options.repository, options.pullRequestId, options.project); + if (!pullRequest) { + throw new Error(`Pull request #${options.pullRequestId} not found`); + } + + // Skip if no merge conflicts + if (options.skipIfNoConflicts && pullRequest.mergeStatus !== PullRequestAsyncStatus.Conflicts) { + console.info(` - Skipping update as pull request has no merge conflicts.`); + return true; + } + + // Skip if the pull request has been modified by another user + const commits = await git.getPullRequestCommits(options.repository, options.pullRequestId, options.project); + if (options.skipIfCommitsFromUsersOtherThan && commits.some(c => c.author?.email !== options.skipIfCommitsFromUsersOtherThan)) { + console.info(` - Skipping update as pull request has been modified by another user.`); + return true; + } + + // Push changes to the source branch + console.info(` - Pushing ${options.changes.length} change(s) branch '${pullRequest.sourceRefName}'...`); + const push = await git.createPush( + { + refUpdates: [ + { + name: pullRequest.sourceRefName, + oldObjectId: pullRequest.lastMergeSourceCommit.commitId + } + ], + commits: [ + { + comment: (pullRequest.mergeStatus === PullRequestAsyncStatus.Conflicts) + ? "Resolve merge conflicts" + : "Update dependency files", + changes: options.changes.map(change => { + return { + changeType: change.changeType, + item: { + path: normalizeDevOpsPath(change.path) + }, + newContent: { + content: Buffer.from(change.content, change.encoding).toString('base64'), + contentType: ItemContentType.Base64Encoded + } + }; + }) + } + ] + }, + options.repository, + options.project + ); + + console.info(` - Pull request #${options.pullRequestId} was updated successfully.`); + return true; + } + catch (e) { + error(`Failed to update pull request: ${e}`); + console.error(e); + return false; + } + } + + /** + * Approve a pull request + * @param options + * @returns + */ + public async approvePullRequest(options: { + project: string, + repository: string, + pullRequestId: number + }): Promise { + console.info(`Approving pull request #${options.pullRequestId}...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Approve the pull request + console.info(` - Creating reviewer vote on pull request...`); + await git.createPullRequestReviewer( + { + vote: 10, // 10 - approved 5 - approved with suggestions 0 - no vote -5 - waiting for author -10 - rejected + isReapprove: true + }, + options.repository, + options.pullRequestId, + userId, + options.project + ); + + console.info(` - Pull request #${options.pullRequestId} was approved.`); + } + catch (e) { + error(`Failed to approve pull request: ${e}`); + console.error(e); + return false; + } + } + + /** + * Close a pull request + * @param options + * @returns + */ + public async closePullRequest(options: { + project: string, + repository: string, + pullRequestId: number, + comment: string, + deleteSourceBranch: boolean + }): Promise { + console.info(`Closing pull request #${options.pullRequestId}...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Add a comment to the pull request, if supplied + if (options.comment) { + console.info(` - Adding comment to pull request...`); + await git.createThread( + { + status: CommentThreadStatus.Closed, + comments: [ + { + author: { + id: userId + }, + content: options.comment, + commentType: CommentType.System + } + ] + }, + options.repository, + options.pullRequestId, + options.project + ); + } + + // Close the pull request + console.info(` - Abandoning pull request...`); + const pullRequest = await git.updatePullRequest( + { + status: PullRequestStatus.Abandoned, + closedBy: { + id: userId + } + }, + options.repository, + options.pullRequestId, + options.project + ); + + // Delete the source branch if required + if (options.deleteSourceBranch) { + console.info(` - Deleting source branch...`); + await git.updateRef( + { + name: `refs/heads/${pullRequest.sourceRefName}`, + oldObjectId: pullRequest.lastMergeSourceCommit.commitId, + newObjectId: "0000000000000000000000000000000000000000", + isLocked: false + }, + options.repository, + '', + options.project + ); + } + + console.info(` - Pull request #${options.pullRequestId} was closed successfully.`); + return true; + } + catch (e) { + error(`Failed to close pull request: ${e}`); + console.error(e); + return false; + } + } + + /** + * Get project properties + * @param project + * @param valueBuilder + * @returns + */ + public async getProjectProperties(project: string): Promise | undefined> { + try { + const core = await this.connection.getCoreApi(); + const projects = await core.getProjects(); + const projectGuid = projects?.find(p => p.name === project)?.id; + const properties = await core.getProjectProperties(projectGuid); + return properties + .map(p => ({ [p.name]: p.value })) + .reduce((a, b) => ({ ...a, ...b }), {}); + + } + catch (e) { + error(`Failed to get project properties: ${e}`); + console.error(e); + return undefined; + } + } + + /** + * Update a project property + * @param project + * @param name + * @param valueBuilder + * @returns + */ + public async updateProjectProperty(project: string, name: string, valueBuilder: (existingValue: string) => string): Promise { + try { + + // Get the existing project property value + const core = await this.connection.getCoreApi(); + const projects = await core.getProjects(); + const projectGuid = projects?.find(p => p.name === project)?.id; + const properties = await core.getProjectProperties(projectGuid); + const propertyValue = properties?.find(p => p.name === name)?.value; + + // Update the project property + await core.setProjectProperties( + undefined, + projectGuid, + [ + { + op: "add", + path: "/" + name, + value: valueBuilder(propertyValue || "") + } + ] + ); + + } + catch (e) { + error(`Failed to update project property '${name}': ${e}`); + console.error(e); + } + } +} + +function normalizeDevOpsPath(path: string): string { + // Convert backslashes to forward slashes, convert './' => '/' and ensure the path starts with a forward slash if it doesn't already, this is how DevOps paths are formatted + return path.replace(/\\/g, "/").replace(/^\.\//, "/").replace(/^([^/])/, "/$1"); +} + +function mergeCommitMessage(id: number, title: string, description: string): string { + // + // The merge commit message should contain the PR number and title for tracking. + // This is the default behaviour in Azure DevOps. + // Example: + // Merged PR 24093: Bump Tingle.Extensions.Logging.LogAnalytics from 3.4.2-ci0005 to 3.4.2-ci0006 + // + // Bumps [Tingle.Extensions.Logging.LogAnalytics](...) from 3.4.2-ci0005 to 3.4.2-ci0006 + // - [Release notes](....) + // - [Changelog](....) + // - [Commits](....) + // + // There appears to be a DevOps bug when setting "completeOptions" with a "mergeCommitMessage" even when truncated to 4000 characters. + // The error message is: + // Invalid argument value. + // Parameter name: Completion options have exceeded the maximum encoded length (4184/4000) + // + // The effective limit seems to be about 3500 characters: + // https://developercommunity.visualstudio.com/t/raise-the-character-limit-for-pull-request-descrip/365708#T-N424531 + // + return `Merged PR ${id}: ${title}\n\n${description}`.slice(0, 3500); +} + +function isGuid(guid: string): boolean { + const regex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + return regex.test(guid); +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IFileChange.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IFileChange.ts new file mode 100644 index 00000000..bb2f06cf --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IFileChange.ts @@ -0,0 +1,11 @@ +import { VersionControlChangeType } from "azure-devops-node-api/interfaces/TfvcInterfaces"; + +/** + * File change + */ +export interface IFileChange { + changeType: VersionControlChangeType, + path: string, + content: string, + encoding: string +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequest.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequest.ts new file mode 100644 index 00000000..11f00c12 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequest.ts @@ -0,0 +1,37 @@ +import { GitPullRequestMergeStrategy } from "azure-devops-node-api/interfaces/GitInterfaces"; +import { IFileChange } from "./IFileChange"; + +/** + * Pull request creation + */ +export interface IPullRequest { + project: string, + repository: string, + source: { + commit: string, + branch: string + }, + target: { + branch: string + }, + author?: { + email: string, + name: string + }, + title: string, + description: string, + commitMessage: string, + autoComplete?: { + ignorePolicyConfigIds?: number[], + mergeStrategy?: GitPullRequestMergeStrategy + }, + assignees?: string[], + reviewers?: string[], + labels?: string[], + workItems?: string[], + changes: IFileChange[], + properties?: { + name: string, + value: string + }[] +}; diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequestProperties.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequestProperties.ts new file mode 100644 index 00000000..d09c5ea3 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequestProperties.ts @@ -0,0 +1,11 @@ + +/** + * Pull request properties + */ +export interface IPullRequestProperties { + id: number, + properties?: { + name: string, + value: string + }[] +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/resolveAzureDevOpsIdentities.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/resolveAzureDevOpsIdentities.ts new file mode 100644 index 00000000..77e0a617 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/resolveAzureDevOpsIdentities.ts @@ -0,0 +1,161 @@ +import axios from 'axios'; +import * as tl from 'azure-pipelines-task-lib/task'; +import extractOrganization from '../extractOrganization'; + +export interface IIdentity { + /** + * The identity id to use for PR reviewer or assignee Id. + */ + id: string; + /** + * Human readable Username. + */ + displayName?: string; + /** + * The provided input to use for searching an identity. + */ + input: string; +} + +/** + * Resolves the given input email addresses to an array of IIdentity information. + * It also handles non email input, which is assumed to be already an identity id + * to pass as reviewer id to an PR. + * + * @param organizationUrl + * @param inputs + * @returns + */ +export async function resolveAzureDevOpsIdentities(organizationUrl: URL, inputs: string[]): Promise { + const result: IIdentity[] = []; + + tl.debug(`Attempting to fetch configuration file via REST API ...`); + for (const input of inputs) { + if (input.indexOf('@') > 0) { + // input is email to look-up + const identityInfo = await querySubject(organizationUrl, input); + if (identityInfo) { + result.push(identityInfo); + } + } else { + // input is already identity id + result.push({ id: input, input: input }); + } + } + return result; +} + +/** + * Returns whether the extension is run in a hosted environment (as opposed to an on-premise environment). + * In Azure DevOps terms, hosted environment is also known as "Azure DevOps Services" and on-premise environment is known as + * "Team Foundation Server" or "Azure DevOps Server". + */ +export function isHostedAzureDevOps(uri: URL): boolean { + const hostname = uri.hostname.toLowerCase(); + return hostname === 'dev.azure.com' || hostname.endsWith('.visualstudio.com'); +} + +function decodeBase64(input: string): string { + return Buffer.from(input, 'base64').toString('utf8'); +} + +function encodeBase64(input: string): string { + return Buffer.from(input, 'utf8').toString('base64'); +} + +function isSuccessStatusCode(statusCode?: number): boolean { + return statusCode >= 200 && statusCode <= 299; +} + +async function querySubject(organizationUrl: URL, email: string): Promise { + if (isHostedAzureDevOps(organizationUrl)) { + const organization: string = extractOrganization(organizationUrl.toString()); + return await querySubjectHosted(organization, email); + } else { + return await querySubjectOnPrem(organizationUrl, email); + } +} + +/** + * Make the HTTP Request for an OnPrem Azure DevOps Server to resolve an email to an IIdentity + * @param organizationUrl + * @param email + * @returns + */ +async function querySubjectOnPrem(organizationUrl: URL, email: string): Promise { + const url = `${organizationUrl}_apis/identities?searchFilter=MailAddress&queryMembership=None&filterValue=${email}`; + tl.debug(`GET ${url}`); + try { + const response = await axios.get(url, { + headers: { + Authorization: `Basic ${encodeBase64('PAT:' + tl.getVariable('System.AccessToken'))}`, + Accept: 'application/json;api-version=5.0', + }, + }); + + if (isSuccessStatusCode(response.status)) { + return { + id: response.data.value[0]?.id, + displayName: response.data.value[0]?.providerDisplayName, + input: email, + }; + } + } catch (error) { + const responseStatusCode = error?.response?.status; + tl.debug(`HTTP Response Status: ${responseStatusCode}`); + if (responseStatusCode > 400 && responseStatusCode < 500) { + tl.debug(`Access token is ${tl.getVariable('System.AccessToken')?.length > 0 ? 'not' : ''} null or empty.`); + throw new Error(`The access token provided is empty or does not have permissions to access '${url}'`); + } else { + throw error; + } + } +} + +/** + * * Make the HTTP Request for a hosted Azure DevOps Service, to resolve an email to an IIdentity + * @param organization + * @param email + * @returns + */ +async function querySubjectHosted(organization: string, email: string): Promise { + // make HTTP request + const url = `https://vssps.dev.azure.com/${organization}/_apis/graph/subjectquery`; + tl.debug(`GET ${url}`); + try { + const response = await axios.post(url, { + headers: { + 'Authorization': `Basic ${encodeBase64('PAT:' + tl.getVariable('System.AccessToken'))}`, + 'Accept': 'application/json;api-version=6.0-preview.1', + 'Content-Type': 'application/json', + }, + data: { + query: email, + subjectKind: ['User'], + }, + }); + + tl.debug(`Got Http Response: ${response.status}`); + + if (!isSuccessStatusCode(response.status) || response.data.value.length === 0) { + throw new Error('Failed to resolve given email in organization'); + } + + const descriptor: string = response.data.value[0]?.descriptor || ''; + const id = decodeBase64(descriptor.substring(descriptor.indexOf('.') + 1)); + return { + id: id, + displayName: response.data.value[0]?.displayName, + input: email, + }; + } catch (error) { + const responseStatusCode = error?.response?.status; + tl.debug(`HTTP Response Status: ${responseStatusCode}`); + if (responseStatusCode > 400 && responseStatusCode < 500) { + tl.debug(`Access token is ${tl.getVariable('System.AccessToken')?.length > 0 ? 'not' : ''} null or empty.`); + throw new Error(`The access token provided is empty or does not have permissions to access '${url}'`); + } else { + throw error; + } + } +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/convertPlaceholder.ts b/extension/tasks/dependabot/dependabotV2/utils/convertPlaceholder.ts new file mode 100644 index 00000000..d4e96d5d --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/convertPlaceholder.ts @@ -0,0 +1,27 @@ +import { getVariable } from 'azure-pipelines-task-lib/task'; + +function convertPlaceholder(input: string): string { + var matches: RegExpExecArray[] = extractPlaceholder(input); + var result = input; + for (const match of matches) { + var placeholder = match[0]; + var name = match[1]; + var value = getVariable(name) ?? placeholder; + result = result.replace(placeholder, value); + } + return result; +} + +function extractPlaceholder(input: string) { + const regexp: RegExp = new RegExp('\\${{\\s*([a-zA-Z_]+[a-zA-Z0-9\\._-]*)\\s*}}', 'g'); + + return matchAll(input, regexp); +} + +function matchAll(input: string, rExp: RegExp, matches: Array = []) { + const matchIfAny = rExp.exec(input); + matchIfAny && matches.push(matchIfAny) && matchAll(input, rExp, matches); + return matches; +} + +export { convertPlaceholder, extractPlaceholder }; diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotCli.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotCli.ts new file mode 100644 index 00000000..f3781689 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotCli.ts @@ -0,0 +1,195 @@ +import { debug, warning, error } from "azure-pipelines-task-lib/task" +import { which, tool } from "azure-pipelines-task-lib/task" +import { ToolRunner } from "azure-pipelines-task-lib/toolrunner" +import { IDependabotUpdateOutputProcessor } from "./interfaces/IDependabotUpdateOutputProcessor"; +import { IDependabotUpdateOperationResult } from "./interfaces/IDependabotUpdateOperationResult"; +import { IDependabotUpdateOperation } from "./interfaces/IDependabotUpdateOperation"; +import * as yaml from 'js-yaml'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { IDependabotUpdateJobConfig } from "./interfaces/IDependabotUpdateJobConfig"; + +/** + * Wrapper class for running updates using dependabot-cli + */ +export class DependabotCli { + private readonly jobsPath: string; + private readonly toolImage: string; + private readonly outputProcessor: IDependabotUpdateOutputProcessor; + private readonly debug: boolean; + + private toolPath: string; + + public static readonly CLI_IMAGE_LATEST = "github.com/dependabot/cli/cmd/dependabot@latest"; + + constructor(cliToolImage: string, outputProcessor: IDependabotUpdateOutputProcessor, debug: boolean) { + this.jobsPath = path.join(os.tmpdir(), 'dependabot-jobs'); + this.toolImage = cliToolImage; + this.outputProcessor = outputProcessor; + this.debug = debug; + this.ensureJobsPathExists(); + } + + /** + * Run dependabot update job + * @param operation + * @param options + * @returns + */ + public async update( + operation: IDependabotUpdateOperation, + options?: { + collectorImage?: string, + proxyImage?: string, + updaterImage?: string + } + ): Promise { + + // Find the dependabot tool path, or install it if missing + const dependabotPath = await this.getDependabotToolPath(); + + // Create the job directory + const jobId = operation.job.id; + const jobPath = path.join(this.jobsPath, jobId.toString()); + const jobInputPath = path.join(jobPath, 'job.yaml'); + const jobOutputPath = path.join(jobPath, 'scenario.yaml'); + this.ensureJobsPathExists(); + if (!fs.existsSync(jobPath)) { + fs.mkdirSync(jobPath); + } + + // Compile dependabot cmd arguments + // See: https://github.com/dependabot/cli/blob/main/cmd/dependabot/internal/cmd/root.go + // https://github.com/dependabot/cli/blob/main/cmd/dependabot/internal/cmd/update.go + let dependabotArguments = [ + "update", "-f", jobInputPath, "-o", jobOutputPath + ]; + if (options?.collectorImage) { + dependabotArguments.push("--collector-image", options.collectorImage); + } + if (options?.proxyImage) { + dependabotArguments.push("--proxy-image", options.proxyImage); + } + if (options?.updaterImage) { + dependabotArguments.push("--updater-image", options.updaterImage); + } + + // Generate the job input file + writeJobConfigFile(jobInputPath, operation); + + // Run dependabot update + if (!fs.existsSync(jobOutputPath) || fs.statSync(jobOutputPath)?.size == 0) { + console.info(`Running Dependabot update job '${jobInputPath}'...`); + const dependabotTool = tool(dependabotPath).arg(dependabotArguments); + const dependabotResultCode = await dependabotTool.execAsync({ + failOnStdErr: false, + ignoreReturnCode: true + }); + if (dependabotResultCode != 0) { + error(`Dependabot failed with exit code ${dependabotResultCode}`); + } + } + + // Process the job output + const operationResults = Array(); + if (fs.existsSync(jobOutputPath)) { + const jobOutputs = readJobScenarioOutputFile(jobOutputPath); + if (jobOutputs?.length > 0) { + console.info(`Processing outputs from '${jobOutputPath}'...`); + for (const output of jobOutputs) { + // Documentation on the scenario model can be found here: + // https://github.com/dependabot/cli/blob/main/internal/model/scenario.go + const type = output['type']; + const data = output['expect']?.['data']; + var operationResult = { + success: true, + error: null, + output: { + type: type, + data: data + } + }; + try { + operationResult.success = await this.outputProcessor.process(operation, type, data); + } + catch (e) { + operationResult.success = false; + operationResult.error = e; + } + finally { + operationResults.push(operationResult); + } + } + } + } + + return operationResults.length > 0 ? operationResults : undefined; + } + + // Get the dependabot tool path and install if missing + private async getDependabotToolPath(installIfMissing: boolean = true): Promise { + + debug('Checking for `dependabot` install...'); + this.toolPath ||= which("dependabot", false); + if (this.toolPath) { + return this.toolPath; + } + if (!installIfMissing) { + throw new Error("Dependabot CLI install not found"); + } + + console.info("Dependabot CLI install was not found, installing now with `go install dependabot`..."); + const goTool: ToolRunner = tool(which("go", true)); + goTool.arg(["install", this.toolImage]); + goTool.execSync(); + + // Depending on how Go is configured on the host agent, the "go/bin" path may not be in the PATH environment variable. + // If dependabot still cannot be found using `which()` after install, we must manually resolve the path; + // It will either be "$GOPATH/bin/dependabot" or "$HOME/go/bin/dependabot", if GOPATH is not set. + const goBinPath = process.env.GOPATH ? path.join(process.env.GOPATH, 'bin') : path.join(os.homedir(), 'go', 'bin'); + return this.toolPath ||= which("dependabot", false) || path.join(goBinPath, 'dependabot'); + } + + // Create the jobs directory if it does not exist + private ensureJobsPathExists(): void { + if (!fs.existsSync(this.jobsPath)) { + fs.mkdirSync(this.jobsPath); + } + } + + // Clean up the jobs directory and its contents + public cleanup(): void { + if (fs.existsSync(this.jobsPath)) { + fs.rmSync(this.jobsPath, { + recursive: true, + force: true + }); + } + } +} + +// Documentation on the job model can be found here: +// https://github.com/dependabot/cli/blob/main/internal/model/job.go +function writeJobConfigFile(path: string, config: IDependabotUpdateJobConfig): void { + fs.writeFileSync(path, yaml.dump({ + job: config.job, + credentials: config.credentials + })); +} + +// Documentation on the scenario model can be found here: +// https://github.com/dependabot/cli/blob/main/internal/model/scenario.go +function readJobScenarioOutputFile(path: string): any[] { + const scenarioContent = fs.readFileSync(path, 'utf-8'); + if (!scenarioContent || typeof scenarioContent !== 'string') { + return []; // No outputs or failed scenario + } + + const scenario: any = yaml.load(scenarioContent); + if (scenario === null || typeof scenario !== 'object') { + throw new Error('Invalid scenario object'); + } + + return scenario['output'] || []; +} \ No newline at end of file diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotJobBuilder.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotJobBuilder.ts new file mode 100644 index 00000000..d355c6bd --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotJobBuilder.ts @@ -0,0 +1,243 @@ +import { error, warning, debug } from "azure-pipelines-task-lib"; +import { ISharedVariables } from "../getSharedVariables"; +import { IDependabotAllowCondition, IDependabotGroup, IDependabotRegistry, IDependabotUpdate } from "../dependabot/interfaces/IDependabotConfig"; +import { IDependabotUpdateOperation } from "./interfaces/IDependabotUpdateOperation"; +import * as crypto from 'crypto'; + +/** + * Wrapper class for building dependabot update job objects + */ +export class DependabotJobBuilder { + + /** + * Create a dependabot update job that updates all dependencies for a package ecyosystem + * @param taskInputs + * @param update + * @param registries + * @param dependencyList + * @param existingPullRequests + * @returns + */ + public static newUpdateAllJob( + taskInputs: ISharedVariables, + id: string, + update: IDependabotUpdate, + registries: Record, + dependencyList: any[], + existingPullRequests: any[] + ): IDependabotUpdateOperation { + const packageEcosystem = update["package-ecosystem"]; + const securityUpdatesOnly = update["open-pull-requests-limit"] == 0; + const updateDependencyNames = securityUpdatesOnly ? mapDependenciesForSecurityUpdate(dependencyList) : undefined; + return buildUpdateJobConfig( + `update-${id}-${packageEcosystem}-${securityUpdatesOnly ? 'security-only' : 'all'}`, + taskInputs, + update, + registries, + false, + undefined, + updateDependencyNames, + existingPullRequests + ); + } + + /** + * Create a dependabot update job that updates a single pull request + * @param taskInputs + * @param update + * @param registries + * @param existingPullRequests + * @param pullRequestToUpdate + * @returns + */ + public static newUpdatePullRequestJob( + taskInputs: ISharedVariables, + id: string, + update: IDependabotUpdate, + registries: Record, + existingPullRequests: any[], + pullRequestToUpdate: any + ): IDependabotUpdateOperation { + const dependencyGroupName = pullRequestToUpdate['dependency-group-name']; + const dependencies = (dependencyGroupName ? pullRequestToUpdate['dependencies'] : pullRequestToUpdate)?.map(d => d['dependency-name']); + return buildUpdateJobConfig( + `update-pr-${id}`, + taskInputs, + update, + registries, + true, + dependencyGroupName, + dependencies, + existingPullRequests + ); + } + +} + +function buildUpdateJobConfig( + id: string, + taskInputs: ISharedVariables, + update: IDependabotUpdate, + registries: Record, + updatingPullRequest: boolean, + updateDependencyGroupName: string | undefined, + updateDependencyNames: string[] | undefined, + existingPullRequests: any[]) { + const hasMultipleDirectories = update.directories?.length > 1; + return { + config: update, + job: { + 'id': id, + 'package-manager': update["package-ecosystem"], + 'update-subdependencies': true, // TODO: add config for this? + 'updating-a-pull-request': updatingPullRequest, + 'dependency-group-to-refresh': updateDependencyGroupName, + 'dependency-groups': mapGroupsFromDependabotConfigToJobConfig(update.groups), + 'dependencies': updateDependencyNames, + 'allowed-updates': mapAllowedUpdatesFromDependabotConfigToJobConfig(update.allow), + 'ignore-conditions': mapIgnoreConditionsFromDependabotConfigToJobConfig(update.ignore), + 'security-updates-only': update["open-pull-requests-limit"] == 0, + 'security-advisories': [], // TODO: add config for this! + 'source': { + 'provider': 'azure', + 'api-endpoint': taskInputs.apiEndpointUrl, + 'hostname': taskInputs.hostname, + 'repo': `${taskInputs.organization}/${taskInputs.project}/_git/${taskInputs.repository}`, + 'branch': update["target-branch"], + 'commit': undefined, // use latest commit of target branch + 'directory': hasMultipleDirectories ? undefined : update.directory || '/', + 'directories': hasMultipleDirectories ? update.directories : undefined + }, + 'existing-pull-requests': existingPullRequests.filter(pr => !pr['dependency-group-name']), + 'existing-group-pull-requests': existingPullRequests.filter(pr => pr['dependency-group-name']), + 'commit-message-options': update["commit-message"] === undefined ? undefined : { + 'prefix': update["commit-message"]?.["prefix"], + 'prefix-development': update["commit-message"]?.["prefix-development"], + 'include-scope': update["commit-message"]?.["include"], + }, + 'experiments': taskInputs.experiments, + 'max-updater-run-time': undefined, // TODO: add config for this? + 'reject-external-code': (update["insecure-external-code-execution"]?.toLocaleLowerCase() == "allow"), + 'repo-private': undefined, // TODO: add config for this? + 'repo-contents-path': undefined, // TODO: add config for this? + 'requirements-update-strategy': mapVersionStrategyToRequirementsUpdateStrategy(update["versioning-strategy"]), + 'lockfile-only': update["versioning-strategy"] === 'lockfile-only', + 'vendor-dependencies': update.vendor, + 'debug': taskInputs.debug + }, + credentials: mapRegistryCredentialsFromDependabotConfigToJobConfig(taskInputs, registries) + }; +} + +function mapDependenciesForSecurityUpdate(dependencyList: any[]): string[] { + if (!dependencyList || dependencyList.length == 0) { + // This happens when no previous dependency list snapshot exists yet; + // TODO: Find a way to discover dependencies for a first-time security-only update (no existing dependency list snapshot). + // It would be nice if we could use dependabot-cli for this (e.g. `dependabot --discover-only`), but this is not supported currently. + // TODO: Open a issue in dependabot-cli project, ask how we should handle this scenario. + warning( + "Security updates can only be performed if there is a previous dependency list snapshot available, but there is none as you have not completed a successful update job yet. " + + "Dependabot does not currently support discovering vulnerable dependencies during security-only updates and it is likely that this update operation will fail." + ); + + // Attempt to do a security update for "all dependencies"; it will probably fail this is not supported in dependabot-updater yet, but it is best we can do... + return []; + } + + // Return only dependencies that are vulnerable, ignore the rest + const dependencyNames = dependencyList.map(dependency => dependency["name"]); + const dependencyVulnerabilities = {}; // TODO: getGitHubSecurityAdvisoriesForDependencies(dependencyNames); + return dependencyNames.filter(dependency => dependencyVulnerabilities[dependency]?.length > 0); +} + +function mapGroupsFromDependabotConfigToJobConfig(dependencyGroups: Record): any[] { + if (!dependencyGroups) { + return undefined; + } + return Object.keys(dependencyGroups).map(name => { + const group = dependencyGroups[name]; + return { + 'name': name, + 'applies-to': group["applies-to"], + 'rules': { + 'patterns': group["patterns"], + 'exclude-patterns': group["exclude-patterns"], + 'dependency-type': group["dependency-type"], + 'update-types': group["update-types"] + } + }; + }); +} + +function mapAllowedUpdatesFromDependabotConfigToJobConfig(allowedUpdates: IDependabotAllowCondition[]): any[] { + if (!allowedUpdates) { + return [ + { 'dependency-type': 'all' } // if not explicitly configured, allow all updates + ]; + } + return allowedUpdates.map(allow => { + return { + 'dependency-name': allow["dependency-name"], + 'dependency-type': allow["dependency-type"], + //'update-type': allow["update-type"] // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? + }; + }); +} + +function mapIgnoreConditionsFromDependabotConfigToJobConfig(ignoreConditions: IDependabotAllowCondition[]): any[] { + if (!ignoreConditions) { + return undefined; + } + return ignoreConditions.map(ignore => { + return { + 'dependency-name': ignore["dependency-name"], + //'source': ignore["source"], // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? + 'update-types': ignore["update-types"], + //'updated-at': ignore["updated-at"], // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? + 'version-requirement': (ignore["versions"])?.join(", "), // TODO: Test this, not sure how this should be parsed... + }; + }); +} + +function mapVersionStrategyToRequirementsUpdateStrategy(versioningStrategy: string): string | undefined { + if (!versioningStrategy) { + return undefined; + } + switch (versioningStrategy) { + case 'auto': return undefined; + case 'increase': return 'bump_versions'; + case 'increase-if-necessary': return 'bump_versions_if_necessary'; + case 'lockfile-only': return 'lockfile_only'; + case 'widen': return 'widen_ranges'; + default: throw new Error(`Invalid dependabot.yaml versioning strategy option '${versioningStrategy}'`); + } +} + +function mapRegistryCredentialsFromDependabotConfigToJobConfig(taskInputs: ISharedVariables, registries: Record): any[] { + let registryCredentials = new Array(); + if (taskInputs.systemAccessToken) { + registryCredentials.push({ + type: 'git_source', + host: taskInputs.hostname, + username: taskInputs.systemAccessUser?.trim()?.length > 0 ? taskInputs.systemAccessUser : 'x-access-token', + password: taskInputs.systemAccessToken + }); + } + if (registries) { + for (const key in registries) { + const registry = registries[key]; + registryCredentials.push({ + type: registry.type, + host: registry.host, + url: registry.url, + registry: registry.registry, + username: registry.username, + password: registry.password, + token: registry.token, + 'replaces-base': registry["replaces-base"] + }); + } + } + + return registryCredentials; +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotOutputProcessor.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotOutputProcessor.ts new file mode 100644 index 00000000..8e1a9308 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotOutputProcessor.ts @@ -0,0 +1,346 @@ +import { debug, warning, error } from "azure-pipelines-task-lib/task" +import { ISharedVariables } from "../getSharedVariables"; +import { IDependabotUpdateOperation } from "./interfaces/IDependabotUpdateOperation"; +import { IDependabotUpdateOutputProcessor } from "./interfaces/IDependabotUpdateOutputProcessor"; +import { AzureDevOpsWebApiClient } from "../azure-devops/AzureDevOpsWebApiClient"; +import { GitPullRequestMergeStrategy, VersionControlChangeType } from "azure-devops-node-api/interfaces/GitInterfaces"; +import { IPullRequestProperties } from "../azure-devops/interfaces/IPullRequestProperties"; +import * as path from 'path'; +import * as crypto from 'crypto'; + +/** + * Processes dependabot update outputs using the DevOps API + */ +export class DependabotOutputProcessor implements IDependabotUpdateOutputProcessor { + private readonly prAuthorClient: AzureDevOpsWebApiClient; + private readonly prApproverClient: AzureDevOpsWebApiClient; + private readonly existingPullRequests: IPullRequestProperties[]; + private readonly taskInputs: ISharedVariables; + + // Custom properties used to store dependabot metadata in projects. + // https://learn.microsoft.com/en-us/rest/api/azure/devops/core/projects/set-project-properties + public static PROJECT_PROPERTY_NAME_DEPENDENCY_LIST = "Dependabot.DependencyList"; + + // Custom properties used to store dependabot metadata in pull requests. + // https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-properties + public static PR_PROPERTY_NAME_PACKAGE_MANAGER = "Dependabot.PackageManager"; + public static PR_PROPERTY_NAME_DEPENDENCIES = "Dependabot.Dependencies"; + + public static PR_DEFAULT_AUTHOR_EMAIL = "noreply@github.com"; + public static PR_DEFAULT_AUTHOR_NAME = "dependabot[bot]"; + + constructor(taskInputs: ISharedVariables, prAuthorClient: AzureDevOpsWebApiClient, prApproverClient: AzureDevOpsWebApiClient, existingPullRequests: IPullRequestProperties[]) { + this.taskInputs = taskInputs; + this.prAuthorClient = prAuthorClient; + this.prApproverClient = prApproverClient; + this.existingPullRequests = existingPullRequests; + } + + /** + * Process the appropriate DevOps API actions for the supplied dependabot update output + * @param update + * @param type + * @param data + * @returns + */ + public async process(update: IDependabotUpdateOperation, type: string, data: any): Promise { + console.debug(`Processing output '${type}' with data:`, data); + const sourceRepoParts = update.job.source.repo.split('/'); // "{organisation}/{project}/_git/{repository}"" + const project = sourceRepoParts[1]; + const repository = sourceRepoParts[3]; + switch (type) { + + // Documentation on the 'data' model for each output type can be found here: + // See: https://github.com/dependabot/cli/blob/main/internal/model/update.go + + case 'update_dependency_list': + + // Store the dependency list snapshot in project properties, if configured + if (this.taskInputs.storeDependencyList) { + console.info(`Storing the dependency list snapshot for project '${project}'...`); + await this.prAuthorClient.updateProjectProperty( + project, + DependabotOutputProcessor.PROJECT_PROPERTY_NAME_DEPENDENCY_LIST, + function (existingValue: string) { + const repoDependencyLists = JSON.parse(existingValue || '{}'); + repoDependencyLists[repository] = repoDependencyLists[repository] || {}; + repoDependencyLists[repository][update.job["package-manager"]] = { + 'dependencies': data['dependencies'], + 'dependency-files': data['dependency_files'], + 'last-updated': new Date().toISOString() + }; + + return JSON.stringify(repoDependencyLists); + } + ); + console.info(`Dependency list snapshot was updated for project '${project}'`); + } + + return true; + + case 'create_pull_request': + if (this.taskInputs.skipPullRequests) { + warning(`Skipping pull request creation as 'skipPullRequests' is set to 'true'`); + return true; + } + + // Skip if active pull request limit reached. + const openPullRequestLimit = update.config["open-pull-requests-limit"]; + if (openPullRequestLimit > 0 && this.existingPullRequests.length >= openPullRequestLimit) { + warning(`Skipping pull request creation as the maximum number of active pull requests (${openPullRequestLimit}) has been reached`); + return true; + } + + // Create a new pull request + const dependencies = getPullRequestDependenciesPropertyValueForOutputData(data); + const targetBranch = update.config["target-branch"] || await this.prAuthorClient.getDefaultBranch(project, repository); + const newPullRequestId = await this.prAuthorClient.createPullRequest({ + project: project, + repository: repository, + source: { + commit: data['base-commit-sha'] || update.job.source.commit, + branch: getSourceBranchNameForUpdate(update.job["package-manager"], targetBranch, dependencies) + }, + target: { + branch: targetBranch + }, + author: { + email: this.taskInputs.authorEmail || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_EMAIL, + name: this.taskInputs.authorName || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_NAME + }, + title: data['pr-title'], + description: data['pr-body'], + commitMessage: data['commit-message'], + autoComplete: this.taskInputs.setAutoComplete ? { + ignorePolicyConfigIds: this.taskInputs.autoCompleteIgnoreConfigIds, + mergeStrategy: GitPullRequestMergeStrategy[this.taskInputs.mergeStrategy as keyof typeof GitPullRequestMergeStrategy] + } : undefined, + assignees: update.config.assignees, + reviewers: update.config.reviewers, + labels: update.config.labels?.map((label) => label?.trim()) || [], + workItems: update.config.milestone ? [update.config.milestone] : [], + changes: getPullRequestChangedFilesForOutputData(data), + properties: buildPullRequestProperties(update.job["package-manager"], dependencies) + }) + + // Auto-approve the pull request, if required + if (this.taskInputs.autoApprove && this.prApproverClient && newPullRequestId) { + await this.prApproverClient.approvePullRequest({ + project: project, + repository: repository, + pullRequestId: newPullRequestId + }); + } + + return newPullRequestId > 0; + + case 'update_pull_request': + if (this.taskInputs.skipPullRequests) { + warning(`Skipping pull request update as 'skipPullRequests' is set to 'true'`); + return true; + } + + // Find the pull request to update + const pullRequestToUpdate = this.getPullRequestForDependencyNames(update.job["package-manager"], data['dependency-names']); + if (!pullRequestToUpdate) { + error(`Could not find pull request to update for package manager '${update.job["package-manager"]}' and dependencies '${data['dependency-names'].join(', ')}'`); + return false; + } + + // Update the pull request + const pullRequestWasUpdated = await this.prAuthorClient.updatePullRequest({ + project: project, + repository: repository, + pullRequestId: pullRequestToUpdate.id, + changes: getPullRequestChangedFilesForOutputData(data), + skipIfCommitsFromUsersOtherThan: this.taskInputs.authorEmail || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_EMAIL, + skipIfNoConflicts: true, + }); + + // Re-approve the pull request, if required + if (this.taskInputs.autoApprove && this.prApproverClient && pullRequestWasUpdated) { + await this.prApproverClient.approvePullRequest({ + project: project, + repository: repository, + pullRequestId: pullRequestToUpdate.id + }); + } + + return pullRequestWasUpdated; + + case 'close_pull_request': + if (!this.taskInputs.abandonUnwantedPullRequests) { + warning(`Skipping pull request closure as 'abandonUnwantedPullRequests' is set to 'false'`); + return true; + } + + // Find the pull request to close + const pullRequestToClose = this.getPullRequestForDependencyNames(update.job["package-manager"], data['dependency-names']); + if (!pullRequestToClose) { + error(`Could not find pull request to close for package manager '${update.job["package-manager"]}' and dependencies '${data['dependency-names'].join(', ')}'`); + return false; + } + + // TODO: GitHub Dependabot will close with reason "Superseded by ${new_pull_request_id}" when another PR supersedes it. + // How do we detect this? Do we need to? + + // Close the pull request + return await this.prAuthorClient.closePullRequest({ + project: project, + repository: repository, + pullRequestId: pullRequestToClose.id, + comment: this.taskInputs.commentPullRequests ? getPullRequestCloseReasonForOutputData(data) : undefined, + deleteSourceBranch: true + }); + + case 'mark_as_processed': + // No action required + return true; + + case 'record_ecosystem_versions': + // No action required + break; + + case 'record_update_job_error': + error(`Update job error: ${data['error-type']}`); + console.log(data['error-details']); + return false; + + case 'record_update_job_unknown_error': + error(`Update job unknown error: ${data['error-type']}`); + console.log(data['error-details']); + return false; + + case 'increment_metric': + // No action required + return true; + + default: + warning(`Unknown dependabot output type '${type}', ignoring...`); + return true; + } + } + + private getPullRequestForDependencyNames(packageManager: string, dependencyNames: string[]): IPullRequestProperties | undefined { + return this.existingPullRequests.find(pr => { + return pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER && p.value === packageManager) + && pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES && areEqual(getDependencyNames(JSON.parse(p.value)), dependencyNames)); + }); + } + +} + +export function buildPullRequestProperties(packageManager: string, dependencies: any): any[] { + return [ + { + name: DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER, + value: packageManager + }, + { + name: DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES, + value: JSON.stringify(dependencies) + } + ]; +} + +export function parseProjectDependencyListProperty(properties: Record, repository: string, packageManager: string): any { + const dependencyList = properties?.[DependabotOutputProcessor.PROJECT_PROPERTY_NAME_DEPENDENCY_LIST] || '{}'; + const repoDependencyLists = JSON.parse(dependencyList); + return repoDependencyLists[repository]?.[packageManager]; +} + +export function parsePullRequestProperties(pullRequests: IPullRequestProperties[], packageManager: string | null): Record { + return Object.fromEntries(pullRequests + .filter(pr => { + return pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER && (packageManager === null || p.value === packageManager)); + }) + .map(pr => { + return [ + pr.id, + JSON.parse( + pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES)?.value + ) + ]; + }) + ); +} + +function getSourceBranchNameForUpdate(packageEcosystem: string, targetBranch: string, dependencies: any): string { + const target = targetBranch?.replace(/^\/+|\/+$/g, ''); // strip leading/trailing slashes + if (dependencies['dependency-group-name']) { + // Group dependency update + // e.g. dependabot/nuget/main/microsoft-3b49c54d9e + const dependencyGroupName = dependencies['dependency-group-name']; + const dependencyHash = crypto.createHash('md5').update(dependencies['dependencies'].map(d => `${d['dependency-name']}-${d['dependency-version']}`).join(',')).digest('hex').substring(0, 10); + return `dependabot/${packageEcosystem}/${target}/${dependencyGroupName}-${dependencyHash}`; + } + else { + // Single dependency update + // e.g. dependabot/nuget/main/Microsoft.Extensions.Logging-1.0.0 + const leadDependency = dependencies.length === 1 ? dependencies[0] : null; + return `dependabot/${packageEcosystem}/${target}/${leadDependency['dependency-name']}-${leadDependency['dependency-version']}`; + } +} + +function getPullRequestChangedFilesForOutputData(data: any): any { + return data['updated-dependency-files'].filter((file) => file['type'] === 'file').map((file) => { + let changeType = VersionControlChangeType.None; + if (file['deleted'] === true) { + changeType = VersionControlChangeType.Delete; + } else if (file['operation'] === 'update') { + changeType = VersionControlChangeType.Edit; + } else { + changeType = VersionControlChangeType.Add; + } + return { + changeType: changeType, + path: path.join(file['directory'], file['name']), + content: file['content'], + encoding: file['content_encoding'] + } + }); +} + +function getPullRequestCloseReasonForOutputData(data: any): string { + // The first dependency is the "lead" dependency in a multi-dependency update + const leadDependencyName = data['dependency-names'][0]; + let reason: string = null; + switch (data['reason']) { + case 'dependencies_changed': reason = `Looks like the dependencies have changed`; break; + case 'dependency_group_empty': reason = `Looks like the dependencies in this group are now empty`; break; + case 'dependency_removed': reason = `Looks like ${leadDependencyName} is no longer a dependency`; break; + case 'up_to_date': reason = `Looks like ${leadDependencyName} is up-to-date now`; break; + case 'update_no_longer_possible': reason = `Looks like ${leadDependencyName} can no longer be updated`; break; + } + if (reason?.length > 0) { + reason += ', so this is no longer needed.'; + } + return reason; +} + +function getPullRequestDependenciesPropertyValueForOutputData(data: any): any { + const dependencyGroupName = data['dependency-group']?.['name']; + let dependencies: any = data['dependencies']?.map((dep) => { + return { + 'dependency-name': dep['name'], + 'dependency-version': dep['version'], + 'directory': dep['directory'], + }; + }); + if (dependencyGroupName) { + dependencies = { + 'dependency-group-name': dependencyGroupName, + 'dependencies': dependencies + }; + } + return dependencies; +} + +function getDependencyNames(dependencies: any): string[] { + return (dependencies['dependency-group-name'] ? dependencies['dependencies'] : dependencies)?.map((dep) => dep['dependency-name']?.toString()); +} + +function areEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + return a.every((name) => b.includes(name)); +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateJobConfig.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateJobConfig.ts new file mode 100644 index 00000000..4f2fbe00 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateJobConfig.ts @@ -0,0 +1,104 @@ + +/** + * Represents the Dependabot CLI update job.yaml configuration file options. + */ +export interface IDependabotUpdateJobConfig { + + // The dependabot "updater" job configuration + // See: https://github.com/dependabot/cli/blob/main/internal/model/job.go + // https://github.com/dependabot/dependabot-core/blob/main/updater/lib/dependabot/job.rb + job: { + 'id': string, + 'package-manager': string, + 'update-subdependencies'?: boolean, + 'updating-a-pull-request'?: boolean, + 'dependency-group-to-refresh'?: string, + 'dependency-groups'?: { + 'name': string, + 'applies-to'?: string, + 'rules': { + 'patterns'?: string[] + 'exclude-patterns'?: string[], + 'dependency-type'?: string + 'update-types'?: string[] + } + }[], + 'dependencies'?: string[], + 'allowed-updates'?: { + 'dependency-name'?: string, + 'dependency-type'?: string, + 'update-type'?: string + }[], + 'ignore-conditions'?: { + 'dependency-name'?: string, + 'source'?: string, + 'update-types'?: string[], + 'updated-at'?: string, + 'version-requirement'?: string, + }[], + 'security-updates-only': boolean, + 'security-advisories'?: { + 'dependency-name': string, + 'affected-versions': string[], + 'patched-versions': string[], + 'unaffected-versions': string[], + // TODO: The below configs are not in the dependabot-cli model, but are in the dependabot-core model + 'title'?: string, + 'description'?: string, + 'source-name'?: string, + 'source-url'?: string + }[], + 'source': { + 'provider': string, + 'api-endpoint'?: string, + 'hostname': string, + 'repo': string, + 'branch'?: string, + 'commit'?: string, + 'directory'?: string, + 'directories'?: string[] + }, + 'existing-pull-requests'?: { + 'dependency-name': string, + 'dependency-version': string, + 'directory': string + }[][], + 'existing-group-pull-requests'?: { + 'dependency-group-name': string, + 'dependencies': { + 'dependency-name': string, + 'dependency-version': string, + 'directory': string + }[] + }[], + 'commit-message-options'?: { + 'prefix'?: string, + 'prefix-development'?: string, + 'include-scope'?: string, + }, + 'experiments'?: Record, + 'max-updater-run-time'?: number, + 'reject-external-code'?: boolean, + 'repo-private'?: boolean, + 'repo-contents-path'?: string, + 'requirements-update-strategy'?: string, + 'lockfile-only'?: boolean, + 'vendor-dependencies'?: boolean, + 'debug'?: boolean, + }, + + // The dependabot "proxy" registry credentials + // See: https://github.com/dependabot/dependabot-core/blob/main/common/lib/dependabot/credential.rb + credentials: { + 'type': string, + 'host'?: string, + 'url'?: string, + 'registry'?: string, + 'region'?: string, + 'username'?: string, + 'password'?: string, + 'token'?: string, + 'replaces-base'?: boolean + }[] + +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperation.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperation.ts new file mode 100644 index 00000000..fe8e0eea --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperation.ts @@ -0,0 +1,9 @@ +import { IDependabotUpdate } from "../../dependabot/interfaces/IDependabotConfig" +import { IDependabotUpdateJobConfig } from "./IDependabotUpdateJobConfig" + +/** + * Represents a single Dependabot CLI update operation + */ +export interface IDependabotUpdateOperation extends IDependabotUpdateJobConfig { + config: IDependabotUpdate +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperationResult.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperationResult.ts new file mode 100644 index 00000000..3cc5afa0 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperationResult.ts @@ -0,0 +1,12 @@ + +/** + * Represents the output of a Dependabot CLI update operation + */ +export interface IDependabotUpdateOperationResult { + success: boolean, + error: Error, + output: { + type: string, + data: any + } +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOutputProcessor.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOutputProcessor.ts new file mode 100644 index 00000000..7d37db9b --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOutputProcessor.ts @@ -0,0 +1,16 @@ +import { IDependabotUpdateOperation } from "./IDependabotUpdateOperation"; + +/** + * Represents a processor for Dependabot update operation outputs + */ +export interface IDependabotUpdateOutputProcessor { + + /** + * Process the output of a Dependabot update operation + * @param update The update operation + * @param type The output type (e.g. "create-pull-request", "update-pull-request", etc.) + * @param data The output data object related to the type + */ + process(update: IDependabotUpdateOperation, type: string, data: any): Promise; + +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot/interfaces/IDependabotConfig.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot/interfaces/IDependabotConfig.ts new file mode 100644 index 00000000..e7ed0faf --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot/interfaces/IDependabotConfig.ts @@ -0,0 +1,105 @@ + +/** + * Represents the dependabot.yaml configuration file options. + * See: https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#configuration-options-for-dependabotyml + */ +export interface IDependabotConfig { + + /** + * Mandatory. configuration file version. + **/ + version: number, + + /** + * Mandatory. Configure how Dependabot updates the versions or project dependencies. + * Each entry configures the update settings for a particular package manager. + */ + updates: IDependabotUpdate[], + + /** + * Optional. + * Specify authentication details to access private package registries. + */ + registries?: Record + + /** + * Optional. Enables updates for ecosystems that are not yet generally available. + * https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#enable-beta-ecosystems + */ + 'enable-beta-ecosystems'?: boolean + +} + +export interface IDependabotUpdate { + 'package-ecosystem': string, + 'directory': string, + 'directories': string[], + 'allow'?: IDependabotAllowCondition[], + 'assignees'?: string[], + 'commit-message'?: IDependabotCommitMessage, + 'groups'?: Record, + 'ignore'?: IDependabotIgnoreCondition[], + 'insecure-external-code-execution'?: string, + 'labels': string[], + 'milestone'?: string, + 'open-pull-requests-limit'?: number, + 'pull-request-branch-name'?: IDependabotPullRequestBranchName, + 'rebase-strategy'?: string, + 'registries'?: string[], + 'reviewers'?: string[], + 'schedule'?: IDependabotSchedule, + 'target-branch'?: string, + 'vendor'?: boolean, + 'versioning-strategy'?: string +} + +export interface IDependabotRegistry { + 'type': string, + 'url'?: string, + 'username'?: string, + 'password'?: string, + 'key'?: string, + 'token'?: string, + 'replaces-base'?: boolean, + 'host'?: string, // for terraform and composer only + 'registry'?: string, // for npm only + 'organization'?: string, // for hex-organisation only + 'repo'?: string, // for hex-repository only + 'public-key-fingerprint'?: string, // for hex-repository only +} + +export interface IDependabotGroup { + 'applies-to'?: string, + 'dependency-type'?: string, + 'patterns'?: string[], + 'exclude-patterns'?: string[], + 'update-types'?: string[] +} + +export interface IDependabotAllowCondition { + 'dependency-name'?: string, + 'dependency-type'?: string +} + +export interface IDependabotIgnoreCondition { + 'dependency-name'?: string, + 'versions'?: string[], + 'update-types'?: string[], +} + +export interface IDependabotSchedule { + 'interval'?: string, + 'day'?: string, + 'time'?: string, + 'timezone'?: string, +} + +export interface IDependabotCommitMessage { + 'prefix'?: string, + 'prefix-development'?: string, + 'include'?: string +} + +export interface IDependabotPullRequestBranchName { + 'separator'?: string +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot/parseConfigFile.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot/parseConfigFile.ts new file mode 100644 index 00000000..8380e7f4 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot/parseConfigFile.ts @@ -0,0 +1,305 @@ +import axios from 'axios'; +import * as tl from 'azure-pipelines-task-lib/task'; +import { getVariable } from 'azure-pipelines-task-lib/task'; +import * as fs from 'fs'; +import { load } from 'js-yaml'; +import * as path from 'path'; +import { URL } from 'url'; +import { IDependabotConfig, IDependabotRegistry, IDependabotUpdate } from './interfaces/IDependabotConfig'; +import { convertPlaceholder } from '../convertPlaceholder'; +import { ISharedVariables } from '../getSharedVariables'; + +/** + * Parse the dependabot config YAML file to specify update configuration. + * The file should be located at '/.azuredevops/dependabot.yml' or '/.github/dependabot.yml' + * + * To view YAML file format, visit + * https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#allow + * + * @param taskInputs the input variables of the task + * @returns {IDependabotConfig} config - the dependabot configuration + */ +export default async function parseConfigFile(taskInputs: ISharedVariables): Promise { + const possibleFilePaths = [ + '/.azuredevops/dependabot.yml', + '/.azuredevops/dependabot.yaml', + '/.github/dependabot.yaml', + '/.github/dependabot.yml', + ]; + + let contents: null | string; + + /* + * The configuration file can be available locally if the repository is cloned. + * Otherwise, we should get it via the API which supports 2 scenarios: + * 1. Running the pipeline without cloning, which is useful for huge repositories (multiple submodules or large commit log) + * 2. Running a single pipeline to update multiple repositories https://github.com/tinglesoftware/dependabot-azure-devops/issues/328 + */ + if (taskInputs.repositoryOverridden) { + tl.debug(`Attempting to fetch configuration file via REST API ...`); + for (const fp of possibleFilePaths) { + // make HTTP request + var url = `${taskInputs.organizationUrl}${taskInputs.project}/_apis/git/repositories/${taskInputs.repository}/items?path=${fp}`; + tl.debug(`GET ${url}`); + + try { + var response = await axios.get(url, { + auth: { + username: 'x-access-token', + password: taskInputs.systemAccessToken, + }, + headers: { + Accept: '*/*', // Gotcha!!! without this SH*T fails terribly + }, + }); + if (response.status === 200) { + tl.debug(`Found configuration file at '${url}'`); + contents = response.data; + break; + } + } catch (error) { + var responseStatusCode = error?.response?.status; + + if (responseStatusCode === 404) { + tl.debug(`No configuration file at '${url}'`); + continue; + } else if (responseStatusCode === 401) { + throw new Error(`No access token has been provided to access '${url}'`); + } else if (responseStatusCode === 403) { + throw new Error(`The access token provided does not have permissions to access '${url}'`); + } else { + throw error; + } + } + } + } else { + let rootDir = getVariable('Build.SourcesDirectory'); + for (const fp of possibleFilePaths) { + var filePath = path.join(rootDir, fp); + if (fs.existsSync(filePath)) { + tl.debug(`Found configuration file cloned at ${filePath}`); + contents = fs.readFileSync(filePath, 'utf-8'); + break; + } else { + tl.debug(`No configuration file cloned at ${filePath}`); + } + } + } + + // Ensure we have file contents. Otherwise throw a well readable error. + if (!contents || typeof contents !== 'string') { + throw new Error(`Configuration file not found at possible locations: ${possibleFilePaths.join(', ')}`); + } else { + tl.debug('Configuration file contents read.'); + } + + let config: any = load(contents); + + // Ensure the config object parsed is an object + if (config === null || typeof config !== 'object') { + throw new Error('Invalid dependabot config object'); + } else { + tl.debug('Parsed YAML content from configuration file contents.'); + } + + const rawVersion = config['version']; + let version = -1; + + // Ensure the version has been specified + if (!!!rawVersion) throw new Error('The version must be specified in dependabot.yml'); + + // Try convert the version to integer + try { + version = parseInt(rawVersion, 10); + } catch (e) { + throw new Error('Dependabot version specified must be a valid integer'); + } + + // Ensure the version is == 2 + if (version !== 2) { + throw new Error('Only version 2 of dependabot is supported. Version specified: ' + version); + } + + const updates = parseUpdates(config); + const registries = parseRegistries(config); + validateConfiguration(updates, registries); + + return { + version: version, + updates: updates, + registries: registries, + }; +} + +function parseUpdates(config: any): IDependabotUpdate[] { + var updates: IDependabotUpdate[] = []; + + // Check the updates parsed + var rawUpdates = config['updates']; + + // Check if the array of updates exists + if (!Array.isArray(rawUpdates)) { + throw new Error('Invalid dependabot config object: Dependency updates config array not found'); + } + + // Parse the value of each of the updates obtained from the file + rawUpdates.forEach((update) => { + var dependabotUpdate: IDependabotUpdate = update; + + if (!dependabotUpdate['package-ecosystem']) { + throw new Error("The value 'package-ecosystem' in dependency update config is missing"); + } + + // zero is a valid value + if (!dependabotUpdate['open-pull-requests-limit'] && dependabotUpdate['open-pull-requests-limit'] !== 0) { + dependabotUpdate['open-pull-requests-limit'] = 5; + } + + if (!dependabotUpdate.directory && dependabotUpdate.directories.length === 0) { + throw new Error( + "The values 'directory' and 'directories' in dependency update config is missing, you must specify at least one", + ); + } + + updates.push(dependabotUpdate); + }); + return updates; +} + +function parseRegistries(config: any): Record { + var registries: Record = {}; + + var rawRegistries = config['registries']; + + if (rawRegistries == undefined) return registries; + + // Parse the value of each of the registries obtained from the file + Object.entries(rawRegistries).forEach((item) => { + var registryConfigKey = item[0]; + var registryConfig = item[1]; + + // parse the type + var rawType = registryConfig['type']; + if (!rawType) { + throw new Error(`The value for 'type' in dependency registry config '${registryConfigKey}' is missing`); + } + + // ensure the type is a known one + if (!KnownRegistryTypes.includes(rawType)) { + throw new Error( + `The value '${rawType}' for 'type' in dependency registry config '${registryConfigKey}' is not among the supported values.`, + ); + } + var type = rawType?.replace('-', '_'); + + var parsed: IDependabotRegistry = { type: type }; + registries[registryConfigKey] = parsed; + + // handle special fields for 'hex-organization' types + if (type === 'hex_organization') { + var organization = registryConfig['organization']; + if (!organization) { + throw new Error(`The value 'organization' in dependency registry config '${registryConfigKey}' is missing`); + } + parsed.organization = organization; + } + + // handle special fields for 'hex-repository' types + if (type === 'hex_repository') { + var repo = registryConfig['repo']; + if (!repo) { + throw new Error(`The value 'repo' in dependency registry config '${registryConfigKey}' is missing`); + } + + parsed.repo = repo; + parsed['auth-key'] = registryConfig['auth-key']; + parsed['public-key-fingerprint'] = registryConfig['public-key-fingerprint']; + } + + // parse username, password, key, and token while replacing tokens where necessary + parsed.username = convertPlaceholder(registryConfig['username']); + parsed.password = convertPlaceholder(registryConfig['password']); + parsed.key = convertPlaceholder(registryConfig['key']); + parsed.token = convertPlaceholder(registryConfig['token']); + + // add "replaces-base" if present + var replacesBase = registryConfig['replaces-base']; + if (replacesBase !== undefined) { + parsed['replaces-base'] = replacesBase; + } + + // parse the url + var url = registryConfig['url']; + if (!url && type !== 'hex_organization') { + throw new Error(`The value 'url' in dependency registry config '${registryConfigKey}' is missing`); + } + if (url) { + /* + * Some credentials do not use the 'url' property in the Ruby updater. + * The 'host' and 'registry' properties are derived from the given URL. + * The 'registry' property is derived from the 'url' by stripping off the scheme. + * The 'host' property is derived from the hostname of the 'url'. + * + * 'npm_registry' and 'docker_registry' use 'registry' only. + * 'terraform_registry' uses 'host' only. + * 'composer_repository' uses both 'url' and 'host'. + * 'python_index' uses 'index-url' instead of 'url'. + */ + + if (URL.canParse(url)) { + const parsedUrl = new URL(url); + + const addRegistry = type === 'docker_registry' || type === 'npm_registry'; + if (addRegistry) parsed.registry = url.replace('https://', '').replace('http://', ''); + + const addHost = type === 'terraform_registry' || type === 'composer_repository'; + if (addHost) parsed.host = parsedUrl.hostname; + } + + if (type === 'python_index') parsed['index-url'] = url; + + const skipUrl = + type === 'docker_registry' || + type === 'npm_registry' || + type === 'terraform_registry' || + type === 'python_index'; + if (!skipUrl) parsed.url = url; + } + }); + return registries; +} + +function validateConfiguration(updates: IDependabotUpdate[], registries: Record) { + const configured = Object.keys(registries); + const referenced: string[] = []; + for (const u of updates) referenced.push(...u.registries); + + // ensure there are no configured registries that have not been referenced + const missingConfiguration = referenced.filter((el) => !configured.includes(el)); + if (missingConfiguration.length > 0) { + throw new Error( + `Referenced registries: '${missingConfiguration.join(',')}' have not been configured in the root of dependabot.yml`, + ); + } + + // ensure there are no registries referenced but not configured + const missingReferences = configured.filter((el) => !referenced.includes(el)); + if (missingReferences.length > 0) { + throw new Error(`Registries: '${missingReferences.join(',')}' have not been referenced by any update`); + } +} + +const KnownRegistryTypes = [ + 'composer-repository', + 'docker-registry', + 'git', + 'hex-organization', + 'hex-repository', + 'maven-repository', + 'npm-registry', + 'nuget-feed', + 'python-index', + 'rubygems-server', + 'terraform-registry', +]; + diff --git a/extension/tasks/dependabot/dependabotV2/utils/extractHostname.ts b/extension/tasks/dependabot/dependabotV2/utils/extractHostname.ts new file mode 100644 index 00000000..2126530d --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/extractHostname.ts @@ -0,0 +1,13 @@ +/** + * Extract a dependabot compatible hostname from a TeamFoundationCollection URL + * @param organizationUrl A URL object constructed from the `System.TeamFoundationCollectionUri` variable. + * @returns The hostname component of the {@see organizationUrl} parameter or `dev.azure.com` if the parameter points to an old `*.visualstudio.com` URL. + */ +export default function extractHostname(organizationUrl: URL): string { + const visualStudioUrlRegex = /^(?\S+)\.visualstudio\.com$/iu; + let hostname = organizationUrl.hostname; + if (visualStudioUrlRegex.test(hostname)) { + return 'dev.azure.com'; + } + return hostname; +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/extractOrganization.ts b/extension/tasks/dependabot/dependabotV2/utils/extractOrganization.ts new file mode 100644 index 00000000..2b43561b --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/extractOrganization.ts @@ -0,0 +1,31 @@ +/** + * Extract organization name from organization URL + * + * @param organizationUrl + * + * @returns organization name + */ +export default function extractOrganization(organizationUrl: string): string { + let parts = organizationUrl.split('/'); + + // Check for on-premise style: https://server.domain.com/tfs/x/ + if (parts.length === 6) { + return parts[4]; + } + + // Check for new style: https://dev.azure.com/x/ + if (parts.length === 5) { + return parts[3]; + } + + // Check for old style: https://x.visualstudio.com/ + if (parts.length === 4) { + // Get x.visualstudio.com part. + let part = parts[2]; + + // Return organization part (x). + return part.split('.')[0]; + } + + throw new Error(`Error parsing organization from organization url: '${organizationUrl}'.`); +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/extractVirtualDirectory.ts b/extension/tasks/dependabot/dependabotV2/utils/extractVirtualDirectory.ts new file mode 100644 index 00000000..02db1447 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/extractVirtualDirectory.ts @@ -0,0 +1,23 @@ +/** + * Extract virtual directory from organization URL + * + * Virtual Directories are sometimes used in on-premises + * @param organizationUrl + * + * @returns virtual directory + * + * @example URLs typically are like this:`https://server.domain.com/tfs/x/` and `tfs` is the virtual directory + */ +export default function extractVirtualDirectory(organizationUrl: URL): string { + // extract the pathname from the url then split + //pathname takes the shape '/tfs/x/' + let path = organizationUrl.pathname.split('/'); + + // Virtual Directories are sometimes used in on-premises + // URLs typically are like this: https://server.domain.com/tfs/x/ + // The pathname extracted looks like this: '/tfs/x/' + if (path.length == 4) { + return path[1]; + } + return ''; +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/getAzureDevOpsAccessToken.ts b/extension/tasks/dependabot/dependabotV2/utils/getAzureDevOpsAccessToken.ts new file mode 100644 index 00000000..646747af --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/getAzureDevOpsAccessToken.ts @@ -0,0 +1,26 @@ +import { debug, getEndpointAuthorizationParameter, getInput } from 'azure-pipelines-task-lib/task'; + +/** + * Prepare the access token for Azure DevOps Repos. + * + * + * If the user has not provided one, we use the one from the SystemVssConnection + * + * @returns Azure DevOps Access Token + */ +export default function getAzureDevOpsAccessToken() { + let systemAccessToken: string = getInput('azureDevOpsAccessToken'); + if (systemAccessToken) { + debug('azureDevOpsAccessToken provided, using for authenticating'); + return systemAccessToken; + } + + let serviceConnectionName: string = getInput('azureDevOpsServiceConnection'); + if (serviceConnectionName) { + debug('TFS connection supplied. A token shall be extracted from it.'); + return getEndpointAuthorizationParameter(serviceConnectionName, 'apitoken', false); + } + + debug("No custom token provided. The SystemVssConnection's AccessToken shall be used."); + return getEndpointAuthorizationParameter('SystemVssConnection', 'AccessToken', false); +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/getGithubAccessToken.ts b/extension/tasks/dependabot/dependabotV2/utils/getGithubAccessToken.ts new file mode 100644 index 00000000..ab660444 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/getGithubAccessToken.ts @@ -0,0 +1,53 @@ +import { debug, getEndpointAuthorization, getInput, loc } from 'azure-pipelines-task-lib/task'; + +/** + * Extract access token from Github endpoint + * + * @param githubEndpoint + * @returns + */ +function getGithubEndPointToken(githubEndpoint: string): string { + const githubEndpointObject = getEndpointAuthorization(githubEndpoint, false); + let githubEndpointToken: string = null; + + if (!!githubEndpointObject) { + debug('Endpoint scheme: ' + githubEndpointObject.scheme); + + if (githubEndpointObject.scheme === 'PersonalAccessToken') { + githubEndpointToken = githubEndpointObject.parameters.accessToken; + } else if (githubEndpointObject.scheme === 'OAuth') { + githubEndpointToken = githubEndpointObject.parameters.AccessToken; + } else if (githubEndpointObject.scheme === 'Token') { + githubEndpointToken = githubEndpointObject.parameters.AccessToken; + } else if (githubEndpointObject.scheme) { + throw new Error(loc('InvalidEndpointAuthScheme', githubEndpointObject.scheme)); + } + } + + if (!githubEndpointToken) { + throw new Error(loc('InvalidGitHubEndpoint', githubEndpoint)); + } + + return githubEndpointToken; +} + +/** + * Extract the Github access token from `gitHubAccessToken` and `gitHubConnection` inputs + * + * @returns the Github access token + */ +export default function getGithubAccessToken() { + let gitHubAccessToken: string = getInput('gitHubAccessToken'); + if (gitHubAccessToken) { + debug('gitHubAccessToken provided, using for authenticating'); + return gitHubAccessToken; + } + + const githubEndpointId = getInput('gitHubConnection'); + if (githubEndpointId) { + debug('GitHub connection supplied. A token shall be extracted from it.'); + gitHubAccessToken = getGithubEndPointToken(githubEndpointId); + } + + return gitHubAccessToken; +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/getSharedVariables.ts b/extension/tasks/dependabot/dependabotV2/utils/getSharedVariables.ts new file mode 100644 index 00000000..ee5010b1 --- /dev/null +++ b/extension/tasks/dependabot/dependabotV2/utils/getSharedVariables.ts @@ -0,0 +1,181 @@ +import * as tl from 'azure-pipelines-task-lib/task'; +import extractHostname from './extractHostname'; +import extractOrganization from './extractOrganization'; +import extractVirtualDirectory from './extractVirtualDirectory'; +import getAzureDevOpsAccessToken from './getAzureDevOpsAccessToken'; +import getGithubAccessToken from './getGithubAccessToken'; + +export interface ISharedVariables { + /** URL of the organization. This may lack the project name */ + organizationUrl: URL; + + /** Organization URL protocol */ + protocol: string; + /** Organization URL hostname */ + hostname: string; + /** Organization URL hostname */ + port: string; + /** Organization URL virtual directory */ + virtualDirectory: string; + /** Organization name */ + organization: string; + /** Project name */ + project: string; + /** Repository name */ + repository: string; + /** Whether the repository was overridden via input */ + repositoryOverridden: boolean; + + /** Organisation API endpoint URL */ + apiEndpointUrl: string; + + /** The github token */ + githubAccessToken: string; + /** The access User for Azure DevOps Repos */ + systemAccessUser: string; + /** The access token for Azure DevOps Repos */ + systemAccessToken: string; + + authorEmail?: string; + authorName?: string; + + storeDependencyList: boolean; + + /** Determines if the pull requests that dependabot creates should have auto complete set */ + setAutoComplete: boolean; + /** Merge strategies which can be used to complete a pull request */ + mergeStrategy: string; + /** List of any policy configuration Id's which auto-complete should not wait for */ + autoCompleteIgnoreConfigIds: number[]; + + /** Determines if the pull requests that dependabot creates should be automatically approved */ + autoApprove: boolean; + /** A personal access token of the user that should approve the PR */ + autoApproveUserToken: string; + + experiments: Record; + + /** Determines if verbose log messages are logged */ + debug: boolean; + + /** List of update identifiers to run */ + targetUpdateIds: number[]; + + securityAdvisoriesFile: string | undefined; + + /** Determines whether to skip creating/updating pull requests */ + skipPullRequests: boolean; + /** Determines whether to comment on pull requests which an explanation of the reason for closing */ + commentPullRequests: boolean; + /** Determines whether to abandon unwanted pull requests */ + abandonUnwantedPullRequests: boolean; +} + +/** + * Extract shared variables + * + * @returns shared variables + */ +export default function getSharedVariables(): ISharedVariables { + let organizationUrl = tl.getVariable('System.TeamFoundationCollectionUri'); + + //convert url string into a valid JS URL object + let formattedOrganizationUrl = new URL(organizationUrl); + let protocol: string = formattedOrganizationUrl.protocol.slice(0, -1); + let hostname: string = extractHostname(formattedOrganizationUrl); + let port: string = formattedOrganizationUrl.port; + let virtualDirectory: string = extractVirtualDirectory(formattedOrganizationUrl); + let organization: string = extractOrganization(organizationUrl); + let project: string = encodeURI(tl.getVariable('System.TeamProject')); // encode special characters like spaces + let repository: string = tl.getInput('targetRepositoryName'); + let repositoryOverridden = typeof repository === 'string'; + if (!repositoryOverridden) { + tl.debug('No custom repository provided. The Pipeline Repository Name shall be used.'); + repository = tl.getVariable('Build.Repository.Name'); + } + repository = encodeURI(repository); // encode special characters like spaces + + const virtualDirectorySuffix = virtualDirectory?.length > 0 ? `${virtualDirectory}/` : ''; + let apiEndpointUrl = `${protocol}://${hostname}:${port}/${virtualDirectorySuffix}`; + + // Prepare the access credentials + let githubAccessToken: string = getGithubAccessToken(); + let systemAccessUser: string = tl.getInput('azureDevOpsUser'); + let systemAccessToken: string = getAzureDevOpsAccessToken(); + + let authorEmail: string | undefined = tl.getInput('authorEmail'); + let authorName: string | undefined = tl.getInput('authorName'); + + // Prepare variables for auto complete + let setAutoComplete = tl.getBoolInput('setAutoComplete', false); + let mergeStrategy = tl.getInput('mergeStrategy', true); + let autoCompleteIgnoreConfigIds = tl.getDelimitedInput('autoCompleteIgnoreConfigIds', ';', false).map(Number); + + let storeDependencyList = tl.getBoolInput('storeDependencyList', false); + + // Prepare variables for auto approve + let autoApprove: boolean = tl.getBoolInput('autoApprove', false); + let autoApproveUserToken: string = tl.getInput('autoApproveUserToken'); + + // Convert experiments from comma separated key value pairs to a record + let experiments = tl.getInput('experiments', false)?.split(',')?.reduce( + (acc, cur) => { + let [key, value] = cur.split('=', 2); + acc[key] = value || true; + return acc; + }, + {} as Record + ); + + let debug: boolean = tl.getVariable('System.Debug')?.match(/true/i) ? true : false; + + // Get the target identifiers + let targetUpdateIds = tl.getDelimitedInput('targetUpdateIds', ';', false).map(Number); + + // Prepare other variables + let securityAdvisoriesFile: string | undefined = tl.getInput('securityAdvisoriesFile'); + let skipPullRequests: boolean = tl.getBoolInput('skipPullRequests', false); + let commentPullRequests: boolean = tl.getBoolInput('commentPullRequests', false); + let abandonUnwantedPullRequests: boolean = tl.getBoolInput('abandonUnwantedPullRequests', true); + + return { + organizationUrl: formattedOrganizationUrl, + protocol, + hostname, + port, + virtualDirectory, + organization, + project, + repository, + repositoryOverridden, + + apiEndpointUrl, + + githubAccessToken, + systemAccessUser, + systemAccessToken, + + authorEmail, + authorName, + + storeDependencyList, + + setAutoComplete, + mergeStrategy, + autoCompleteIgnoreConfigIds, + + autoApprove, + autoApproveUserToken, + + experiments, + + debug, + + targetUpdateIds, + securityAdvisoriesFile, + + skipPullRequests, + commentPullRequests, + abandonUnwantedPullRequests, + }; +} diff --git a/extension/vss-extension.json b/extension/vss-extension.json index 7fbf213f..97705657 100644 --- a/extension/vss-extension.json +++ b/extension/vss-extension.json @@ -2,7 +2,7 @@ "manifestVersion": 1, "id": "dependabot", "name": "Dependabot", - "version": "1.0.0", + "version": "2.0.0", "publisher": "tingle-software", "public": false, "targets": [