From 2e9f4296e9ef386dc2f5ce32173ea4fbf0cdc578 Mon Sep 17 00:00:00 2001 From: Mounir Abdous Date: Mon, 16 Dec 2024 11:01:10 -0500 Subject: [PATCH] feat: build - deploy - ai comment --- template/azure-pipeline/ai-comment.yml | 27 ++++ template/azure-pipeline/azure-pipelines.yml | 29 ++++ template/azure-pipeline/build.yml | 35 +++++ template/azure-pipeline/comment-pr.py | 146 ++++++++++++++++++ template/azure-pipeline/commit-validator.yml | 16 ++ template/azure-pipeline/deploy.yml | 52 +++++++ template/azure-pipeline/environments-loop.yml | 20 +++ template/azure.yaml | 8 + template/global.json | 6 +- template/src/Placeholder.AppHost/Program.cs | 15 +- 10 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 template/azure-pipeline/ai-comment.yml create mode 100644 template/azure-pipeline/azure-pipelines.yml create mode 100644 template/azure-pipeline/build.yml create mode 100644 template/azure-pipeline/comment-pr.py create mode 100644 template/azure-pipeline/commit-validator.yml create mode 100644 template/azure-pipeline/deploy.yml create mode 100644 template/azure-pipeline/environments-loop.yml create mode 100644 template/azure.yaml diff --git a/template/azure-pipeline/ai-comment.yml b/template/azure-pipeline/ai-comment.yml new file mode 100644 index 0000000..6e0b19a --- /dev/null +++ b/template/azure-pipeline/ai-comment.yml @@ -0,0 +1,27 @@ +stages: + - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + - stage: Ai_comment + displayName: AI comments on PR + condition: eq(variables['Build.Reason'], 'PullRequest') + jobs: + - job: Comment_trigger + displayName: Manual Trigger for AI comments + pool: server + steps: + - task: ManualValidation@1 + timeoutInMinutes: 5 + inputs: + instructions: "Do you want AI comment on this PR?" + onTimeout: "reject" + - job: Comment_pr + displayName: Analyze and Comment on PR + dependsOn: Comment_trigger + condition: succeeded('Comment_trigger') + steps: + - script: | + python3 -m pip install openai==0.28 requests + python3 azure-pipeline/comment-pr.py + displayName: "Run PR Analysis" + env: + OPENAI_API_KEY: $(OPEN_AI_KEY) + SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/template/azure-pipeline/azure-pipelines.yml b/template/azure-pipeline/azure-pipelines.yml new file mode 100644 index 0000000..835a60a --- /dev/null +++ b/template/azure-pipeline/azure-pipelines.yml @@ -0,0 +1,29 @@ +pr: + branches: + include: + - main + +trigger: + branches: + include: + - main + +name: v1.0$(Rev:.r) + +variables: + - group: AzureVariables + - name: DOTNET_VERSION + value: "9.x" + - name: APP_NAME + value: "placeholder" + - name: AZURE_LOCATION + value: "eastus" + +pool: + vmImage: "ubuntu-latest" + +stages: + - template: commit-validator.yml + - template: build.yml + - template: ai-comment.yml + - template: environments-loop.yml diff --git a/template/azure-pipeline/build.yml b/template/azure-pipeline/build.yml new file mode 100644 index 0000000..e7a6aac --- /dev/null +++ b/template/azure-pipeline/build.yml @@ -0,0 +1,35 @@ +parameters: + - name: buildConfiguration + type: string + default: "Release" + - name: projectPath + type: string + default: "**/*.AppHost/*.csproj" + +stages: + - stage: Build + displayName: Build [multi-env] + condition: succeeded() + jobs: + - job: Build + displayName: Build + steps: + - task: UseDotNet@2 + displayName: "Install .NET $(DOTNET_VERSION) SDK" + inputs: + packageType: "sdk" + version: $(DOTNET_VERSION) + installationPath: $(Agent.ToolsDirectory)/dotnet + + - task: DotNetCoreCLI@2 + displayName: "Restore" + inputs: + command: restore + projects: ${{ parameters.projectPath }} + + - task: DotNetCoreCLI@2 + displayName: "Build" + inputs: + command: build + projects: ${{ parameters.projectPath }} + arguments: "--configuration ${{ parameters.buildConfiguration }} --verbosity detailed" diff --git a/template/azure-pipeline/comment-pr.py b/template/azure-pipeline/comment-pr.py new file mode 100644 index 0000000..1a69c9c --- /dev/null +++ b/template/azure-pipeline/comment-pr.py @@ -0,0 +1,146 @@ +import os +import requests +import openai +import difflib + +API_VERSION = "7.1" + +# Utility Functions + + +def get_env_variable(var_name, required=True): + value = os.getenv(var_name) + if required and not value: + raise EnvironmentError(f"Environment variable '{ + var_name}' is missing.") + return value.strip("/") + + +def make_api_request(url, headers, method="GET", data=None): + response = requests.request(method, url, headers=headers, json=data) + if response.status_code != 200: + raise Exception(f"API call failed: { + response.status_code}, Response: {response.text}") + return response.json() + + +def compare_file_contents(file_path, old_content, new_content): + diff = difflib.unified_diff( + old_content.splitlines(), + new_content.splitlines(), + fromfile=f"{file_path} (old)", + tofile=f"{file_path} (new)" + ) + return "\n".join(diff) + + +def calculate_cost(prompt_tokens, completion_tokens): + input_rate = 0.15 / 1_000_000 # $0.15 per 1M input tokens + output_rate = 0.6 / 1_000_000 # $0.60 per 1M output tokens + return (prompt_tokens * input_rate) + (completion_tokens * output_rate) + + +def main(): + # Load Environment Variables + azure_token = get_env_variable("SYSTEM_ACCESSTOKEN") + openai_api_key = get_env_variable("OPENAI_API_KEY") + organization = get_env_variable("SYSTEM_COLLECTIONURI") + project = get_env_variable("SYSTEM_TEAMPROJECT") + repo_id = get_env_variable("BUILD_REPOSITORY_ID") + pr_id = get_env_variable("SYSTEM_PULLREQUEST_PULLREQUESTID") + + # Configure API and Headers + openai.api_key = openai_api_key + headers = {"Authorization": f"Bearer {azure_token}"} + base_url = f"{organization}/{project}/_apis/git/repositories/{repo_id}" + + # Fetch Pull Request Details + pr_url = f"{base_url}/pullRequests/{pr_id}?api-version=7.0" + pr_details = make_api_request(pr_url, headers) + title = pr_details["title"] + description = pr_details.get("description", "") + target_branch = pr_details["targetRefName"].split("/")[-1] + + # Fetch Iterations and Changed Files + iterations_url = f"{ + base_url}/pullRequests/{pr_id}/iterations?api-version={API_VERSION}" + iterations = make_api_request(iterations_url, headers) + changed_files = set() + + if "value" in iterations: + for iteration in iterations["value"]: + iteration_id = iteration["id"] + commits_url = f"{base_url}/pullRequests/{pr_id}/iterations/{ + iteration_id}/commits?api-version={API_VERSION}" + commits = make_api_request(commits_url, headers)["value"] + + for commit in commits: + commit_id = commit["commitId"] + commit_url = f"{ + base_url}/commits/{commit_id}?api-version={API_VERSION}" + commit_data = make_api_request(commit_url, headers) + changes_url = commit_data["_links"]["changes"]["href"] + changes_data = make_api_request(changes_url, headers) + if "changes" in changes_data: + for change in changes_data["changes"]: + if "item" in change and "path" in change["item"]: + path = change["item"]["path"] + if "." in path.split("/")[-1]: + changed_files.add(path) + + # Analyze File Changes + messages = [ + {"role": "system", "content": "You are a helpful and knowledgeable code reviewer with expertise in clean code practices."}, + {"role": "user", "content": f"Here's a PR titled '{title}' with the following description: { + description}. Please review the implementation carefully."}, + {"role": "user", "content": f"The following files were changed: {changed_files}."} + ] + + for file_path in changed_files: + print(f"Processing file: {file_path}") + try: + # Fetch New Version + new_version_url = f"{base_url}/items/{file_path}?versionType=Commit&version={ + commits[-1]['commitId']}&api-version={API_VERSION}" + new_version_content = requests.get( + new_version_url, headers=headers).text + + # Fetch Previous Version + prev_version_url = f"{base_url}/items?path={ + file_path}&versionDescriptor[versionType]=branch&versionDescriptor[version]={target_branch}&api-version={API_VERSION}" + prev_version_content = requests.get( + prev_version_url, headers=headers).text + + # Generate Diff + diff = compare_file_contents( + file_path, prev_version_content, new_version_content) + messages.append( + {"role": "user", "content": f"Changes for file: {file_path}\n{diff}"}) + + except Exception as e: + print(f"Failed to process file {file_path}: {e}") + continue + + # OpenAI Completion + response = openai.ChatCompletion.create( + model="gpt-4o-mini", + messages=messages, + max_tokens=5000 + ) + comment = response["choices"][0]["message"]["content"] + + # Post Comment + comment_cost = calculate_cost( + response["usage"]["prompt_tokens"], + response["usage"]["completion_tokens"] + ) + comment_url = f"{base_url}/pullRequests/{pr_id}/threads?api-version=7.0" + comment_body = { + "comments": [{"content": f"{comment}\n\nTotal cost: ${comment_cost:.2f}"}], + "status": "active" + } + requests.post(comment_url, headers=headers, json=comment_body) + + +if __name__ == "__main__": + main() diff --git a/template/azure-pipeline/commit-validator.yml b/template/azure-pipeline/commit-validator.yml new file mode 100644 index 0000000..2221076 --- /dev/null +++ b/template/azure-pipeline/commit-validator.yml @@ -0,0 +1,16 @@ +stages: + - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + - stage: Commit_validator + displayName: Conventional Commit Validator + condition: eq(variables['Build.Reason'], 'PullRequest') + jobs: + - job: Commit_validator + displayName: "Validate Conventional Commits" + steps: + - task: CommitMessageValidator@1 + displayName: "Validate Conventional Commits" + inputs: + regExPattern: '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\.\-\p{Extended_Pictographic}]+\))?(!)?: ([\w \p{Extended_Pictographic}])+([\s\S]*)' + regExFlags: "um" + allCommitsMustMatch: true + prMode: true diff --git a/template/azure-pipeline/deploy.yml b/template/azure-pipeline/deploy.yml new file mode 100644 index 0000000..4e93e01 --- /dev/null +++ b/template/azure-pipeline/deploy.yml @@ -0,0 +1,52 @@ +parameters: + - name: environment + type: string + +jobs: + - deployment: "Deploy_${{ parameters.environment }}" + displayName: "Deploy [${{ parameters.environment }}]" + environment: ${{ parameters.environment }} + variables: + AZURE_ENVIRONMENT_NAME: "$(APP_NAME)-${{ parameters.environment }}" + strategy: + runOnce: + deploy: + steps: + - checkout: self + + - task: UseDotNet@2 + displayName: "Install .NET 9 SDK" + inputs: + packageType: "sdk" + version: $(DOTNET_VERSION) + installationPath: $(Agent.ToolsDirectory)/dotnet + + - script: | + curl -fsSL https://aka.ms/install-azd.sh | bash + echo 'Azure Developer CLI installed' + displayName: "Install Azure Developer CLI" + + - task: AzureCLI@2 + displayName: Provision + inputs: + azureSubscription: $(AZURE_SERVICE_CONNECTION_NAME) + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + azd config set auth.useAzCliAuth "true" + azd provision --no-prompt + addSpnToEnvironment: true + visibleAzLogin: true + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_ENV_NAME: $(AZURE_ENVIRONMENT_NAME) + AZURE_LOCATION: $(AZURE_LOCATION) + + - task: AzureCLI@2 + displayName: Deploy + inputs: + azureSubscription: $(AZURE_SERVICE_CONNECTION_NAME) + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + azd deploy --no-prompt diff --git a/template/azure-pipeline/environments-loop.yml b/template/azure-pipeline/environments-loop.yml new file mode 100644 index 0000000..2a49a8d --- /dev/null +++ b/template/azure-pipeline/environments-loop.yml @@ -0,0 +1,20 @@ +parameters: + - name: environments + type: object + default: + # - dev + # - qa + # - uat + # - stag + # - prod + +stages: + - ${{ if not(eq(variables['Build.Reason'], 'PullRequest')) }}: + - ${{ each environment in parameters.environments }}: + - stage: "Deploy_${{ environment }}" + displayName: "Deploy [${{ environment }}]" + dependsOn: Build + jobs: + - template: deploy.yml + parameters: + environment: ${{ environment }} diff --git a/template/azure.yaml b/template/azure.yaml new file mode 100644 index 0000000..abb4b0a --- /dev/null +++ b/template/azure.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: placeholder +services: + app: + language: dotnet + project: ./src/Placeholder.AppHost/Placeholder.AppHost.csproj + host: containerapp diff --git a/template/global.json b/template/global.json index f951e23..45f790c 100644 --- a/template/global.json +++ b/template/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.100", - "rollForward": "disable" + "version": "9.0.101", + "rollForward": "minor" } -} \ No newline at end of file +} diff --git a/template/src/Placeholder.AppHost/Program.cs b/template/src/Placeholder.AppHost/Program.cs index 5d41827..c67304d 100644 --- a/template/src/Placeholder.AppHost/Program.cs +++ b/template/src/Placeholder.AppHost/Program.cs @@ -1,17 +1,18 @@ -using Placeholder.AppHost; - var builder = DistributedApplication.CreateBuilder(args); -var weatherDb = builder.ConfigurePostgresDatabase("weather", builder.AddParameter("postgresUsername"), builder.AddParameter("postgresPassword", secret: true)); +var postgres = builder.AddPostgres("postgres"); + +var weatherDb = postgres.AddDatabase("weather"); -var apiService = builder.AddProject("apiservice") +var apiService = builder + .AddProject("apiservice") .WithExternalHttpEndpoints() .WithReference(weatherDb); -builder.AddProject("migration") - .WithReference(weatherDb); +builder.AddProject("migration").WithReference(weatherDb); -builder.AddProject("webfrontend") +builder + .AddProject("webfrontend") .WithExternalHttpEndpoints() .WithReference(apiService);