diff --git a/.github/hooks/copilot-cli-comment-prefix.json b/.github/hooks/copilot-cli-comment-prefix.json new file mode 100644 index 00000000000..39a1430d172 --- /dev/null +++ b/.github/hooks/copilot-cli-comment-prefix.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "matcher": "powershell|bash|.*(?:add_issue_comment|add_reply_to_pull_request_comment|add_comment_to_pending_review|add_pull_request_review_comment|create_pull_request_review|submit_pending_pull_request_review|pull_request_review_write|discussion_comment_write)$", + "bash": "if command -v python3 >/dev/null 2>&1 && [ -f ./prefix-github-comments.py ]; then python3 ./prefix-github-comments.py || printf '%s\\n' '{}'; else printf '%s\\n' '{}'; fi", + "powershell": "try { if (Test-Path -LiteralPath './prefix-github-comments.ps1' -PathType Leaf) { & './prefix-github-comments.ps1' } else { '{}' } } catch { '{}' }", + "cwd": ".github/hooks/copilot-cli-comment-prefix", + "timeoutSec": 10 + } + ] + } +} diff --git a/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.ps1 b/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.ps1 new file mode 100644 index 00000000000..2f73c35a8b8 --- /dev/null +++ b/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.ps1 @@ -0,0 +1,484 @@ +$copilotGhCommand = Get-Command gh.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +$script:CopilotRealGhPath = if ($null -ne $copilotGhCommand) { $copilotGhCommand.Source } else { $null } +$script:CopilotTargetRepository = 'dotnet/msbuild' +$script:CopilotCommentPrefix = "> [!NOTE]`n> This comment was generated by GitHub Copilot CLI." + +function Add-CopilotCommentPrefix { + param([AllowEmptyString()][string] $Body) + + $normalizedBody = $Body.Replace("`r`n", "`n") + if ($normalizedBody.StartsWith($script:CopilotCommentPrefix, [System.StringComparison]::Ordinal)) { + return $Body + } + + return "$script:CopilotCommentPrefix`n`n$Body" +} + +function ConvertTo-CopilotRepositorySlug { + param([Parameter(Mandatory)][string] $Value) + + $candidate = $Value.Trim() + foreach ($prefix in @( + 'https://github.com/', + 'http://github.com/', + 'git@github.com:', + 'github.com/', + 'https://api.github.com/', + 'http://api.github.com/' + )) { + if ($candidate.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $candidate = $candidate.Substring($prefix.Length) + break + } + } + + $candidate = $candidate.Trim('/') + if ($candidate.StartsWith('repos/', [System.StringComparison]::OrdinalIgnoreCase)) { + $candidate = $candidate.Substring('repos/'.Length) + } + + $parts = $candidate.Split('/') + if ($parts.Count -lt 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { + return $null + } + + $repository = ($parts[1] -split '[?#]', 2)[0] -replace '(?i)\.git$', '' + return "$($parts[0])/$repository".ToLowerInvariant() +} + +function Get-CopilotExplicitRepository { + param([Parameter(Mandatory)][object[]] $Arguments) + + $optionsWithValues = @( + '-F', '-H', '-X', '-b', '-f', '-q', '-t', + '--body', '--body-file', '--cache', '--field', '--header', '--hostname', + '--input', '--jq', '--method', '--raw-field', '--template' + ) + + $index = 0 + while ($index -lt $Arguments.Count) { + $argument = [string]$Arguments[$index] + if ($argument -in @('-R', '--repo')) { + if ($index + 1 -ge $Arguments.Count) { + return [pscustomobject]@{ Found = $true; Repository = $null } + } + + return [pscustomobject]@{ + Found = $true + Repository = ConvertTo-CopilotRepositorySlug -Value ([string]$Arguments[$index + 1]) + } + } + + if ($argument -match '^--repo=(.*)$') { + return [pscustomobject]@{ + Found = $true + Repository = ConvertTo-CopilotRepositorySlug -Value $Matches[1] + } + } + + if ($argument -match '^-R(.+)$') { + return [pscustomobject]@{ + Found = $true + Repository = ConvertTo-CopilotRepositorySlug -Value $Matches[1] + } + } + + if ($argument -in $optionsWithValues -and $index + 1 -lt $Arguments.Count) { + $index += 2 + } + else { + $index++ + } + } + + return [pscustomobject]@{ Found = $false; Repository = $null } +} + +function Get-CopilotCommandOffset { + param([Parameter(Mandatory)][object[]] $Arguments) + + $index = 0 + while ($index -lt $Arguments.Count) { + $argument = [string]$Arguments[$index] + if ($argument -in @('-R', '--repo', '--hostname')) { + if ($index + 1 -ge $Arguments.Count) { + return -1 + } + + $index += 2 + continue + } + + if ($argument -match '^(?:--repo|--hostname)=') { + $index++ + continue + } + + if ($argument -match '^-R.+$') { + $index++ + continue + } + + return $index + } + + return -1 +} + +function Get-CopilotApiEndpoint { + param( + [Parameter(Mandatory)][object[]] $Arguments, + [Parameter(Mandatory)][int] $CommandOffset + ) + + $optionsWithValues = @( + '-F', '-H', '-R', '-X', '-f', '-q', '-t', + '--cache', '--field', '--header', '--hostname', '--input', '--jq', + '--method', '--raw-field', '--repo', '--template' + ) + + $index = $CommandOffset + 1 + while ($index -lt $Arguments.Count) { + $argument = [string]$Arguments[$index] + if ($argument -in $optionsWithValues) { + $index += 2 + continue + } + + if ($argument.StartsWith('-')) { + $index++ + continue + } + + return $argument + } + + return $null +} + +function Test-CopilotTargetsRepository { + param([Parameter(Mandatory)][object[]] $Arguments) + + $explicitRepository = Get-CopilotExplicitRepository -Arguments $Arguments + if ($explicitRepository.Found) { + return $explicitRepository.Repository -ceq $script:CopilotTargetRepository + } + + $commandOffset = Get-CopilotCommandOffset -Arguments $Arguments + if ($commandOffset -lt 0 -or $commandOffset -ge $Arguments.Count) { + return $false + } + + $command = [string]$Arguments[$commandOffset] + $subcommand = if ($commandOffset + 1 -lt $Arguments.Count) { + [string]$Arguments[$commandOffset + 1] + } + else { + '' + } + + if ( + ($command -in @('issue', 'pr') -and $subcommand -eq 'comment') -or + ($command -eq 'pr' -and $subcommand -eq 'review') + ) { + $targetIndex = $commandOffset + 2 + if ($targetIndex -lt $Arguments.Count) { + $target = [string]$Arguments[$targetIndex] + if ( + -not $target.StartsWith('-') -and + $target -match '^(?i:https?://github\.com/|git@github\.com:|github\.com/)' + ) { + return (ConvertTo-CopilotRepositorySlug -Value $target) -ceq $script:CopilotTargetRepository + } + } + + return $true + } + + if ($command -eq 'api') { + $endpoint = Get-CopilotApiEndpoint -Arguments $Arguments -CommandOffset $commandOffset + if ([string]::IsNullOrWhiteSpace($endpoint)) { + return $false + } + + if ($endpoint -match '(?i)(?:^|/)repos/([^/]+)/([^/?#]+)(?:/|$)') { + if ($Matches[1] -ceq '{owner}' -and $Matches[2] -ceq '{repo}') { + return $true + } + + return "$($Matches[1])/$($Matches[2])".ToLowerInvariant() -ceq $script:CopilotTargetRepository + } + + return $true + } + + return $false +} + +function New-CopilotPrefixedBodyFile { + param([Parameter(Mandatory)][string] $Path) + + if ($Path -eq '-') { + return [pscustomobject]@{ + Path = $Path + TemporaryPath = $null + } + } + + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + if (-not [System.IO.File]::Exists($resolvedPath)) { + return [pscustomobject]@{ + Path = $Path + TemporaryPath = $null + } + } + + $body = [System.IO.File]::ReadAllText($resolvedPath) + $prefixedBody = Add-CopilotCommentPrefix -Body $body + if ($prefixedBody -ceq $body) { + return [pscustomobject]@{ + Path = $Path + TemporaryPath = $null + } + } + + $temporaryPath = Join-Path ([System.IO.Path]::GetTempPath()) "$([System.IO.Path]::GetRandomFileName()).md" + [System.IO.File]::WriteAllText($temporaryPath, $prefixedBody, [System.Text.UTF8Encoding]::new($false)) + return [pscustomobject]@{ + Path = $temporaryPath + TemporaryPath = $temporaryPath + } +} + +function New-CopilotPrefixedJsonFile { + param([Parameter(Mandatory)][string] $Path) + + if ($Path -eq '-') { + return [pscustomobject]@{ + Path = $Path + TemporaryPath = $null + } + } + + $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + if (-not [System.IO.File]::Exists($resolvedPath)) { + return [pscustomobject]@{ + Path = $Path + TemporaryPath = $null + } + } + + $payload = [System.IO.File]::ReadAllText($resolvedPath) | ConvertFrom-Json -Depth 100 + $bodyProperty = $payload.PSObject.Properties['body'] + if ($null -eq $bodyProperty -or $bodyProperty.Value -isnot [string]) { + return [pscustomobject]@{ + Path = $Path + TemporaryPath = $null + } + } + + $prefixedBody = Add-CopilotCommentPrefix -Body $bodyProperty.Value + if ($prefixedBody -ceq $bodyProperty.Value) { + return [pscustomobject]@{ + Path = $Path + TemporaryPath = $null + } + } + + $bodyProperty.Value = $prefixedBody + $temporaryPath = Join-Path ([System.IO.Path]::GetTempPath()) "$([System.IO.Path]::GetRandomFileName()).json" + $payloadJson = $payload | ConvertTo-Json -Compress -Depth 100 + [System.IO.File]::WriteAllText($temporaryPath, $payloadJson, [System.Text.UTF8Encoding]::new($false)) + return [pscustomobject]@{ + Path = $temporaryPath + TemporaryPath = $temporaryPath + } +} + +function ConvertTo-CopilotPrefixedGhArguments { + param( + [Parameter(Mandatory)][object[]] $GhArguments, + [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[string]] $TemporaryPaths + ) + + $arguments = [System.Collections.Generic.List[object]]::new() + foreach ($argument in $GhArguments) { + $arguments.Add($argument) + } + + if (-not (Test-CopilotTargetsRepository -Arguments $arguments.ToArray())) { + return [pscustomobject]@{ + Arguments = $arguments.ToArray() + } + } + + $commandOffset = Get-CopilotCommandOffset -Arguments $arguments.ToArray() + $isIssueOrPrComment = ( + $commandOffset -ge 0 -and + $commandOffset + 1 -lt $arguments.Count -and + [string]$arguments[$commandOffset] -in @('issue', 'pr') -and + [string]$arguments[$commandOffset + 1] -eq 'comment' + ) + $isPrReview = ( + $commandOffset -ge 0 -and + $commandOffset + 1 -lt $arguments.Count -and + [string]$arguments[$commandOffset] -eq 'pr' -and + [string]$arguments[$commandOffset + 1] -eq 'review' + ) + + if ($isIssueOrPrComment -or $isPrReview) { + for ($index = $commandOffset + 2; $index -lt $arguments.Count; $index++) { + $argument = [string]$arguments[$index] + + if ($argument -in @('--body', '-b')) { + if ($index + 1 -ge $arguments.Count) { + continue + } + + $arguments[$index + 1] = Add-CopilotCommentPrefix -Body ([string]$arguments[$index + 1]) + $index++ + continue + } + + if ($argument -match '^--body=(.*)$') { + $arguments[$index] = "--body=$(Add-CopilotCommentPrefix -Body $Matches[1])" + continue + } + + if ($argument -in @('--body-file', '-F')) { + if ($index + 1 -ge $arguments.Count) { + continue + } + + $file = New-CopilotPrefixedBodyFile -Path ([string]$arguments[$index + 1]) + $arguments[$index + 1] = $file.Path + if ($null -ne $file.TemporaryPath) { + $TemporaryPaths.Add($file.TemporaryPath) + } + + $index++ + continue + } + + if ($argument -match '^--body-file=(.*)$') { + $file = New-CopilotPrefixedBodyFile -Path $Matches[1] + $arguments[$index] = "--body-file=$($file.Path)" + if ($null -ne $file.TemporaryPath) { + $TemporaryPaths.Add($file.TemporaryPath) + } + } + } + } + + $isApi = $commandOffset -ge 0 -and [string]$arguments[$commandOffset] -eq 'api' + if ($isApi) { + $endpoint = Get-CopilotApiEndpoint -Arguments $arguments.ToArray() -CommandOffset $commandOffset + $isCommentEndpoint = $endpoint -match '(?i)(?:/issues/\d+/comments|/issues/comments/\d+|/pulls/\d+/(?:comments|reviews)|/pulls/comments/\d+|/comments/\d+/replies|/reviews/\d+/comments)' + $explicitMethod = $null + $hasWriteField = $false + + for ($index = $commandOffset + 1; $index -lt $arguments.Count; $index++) { + $argument = [string]$arguments[$index] + + if ($argument -in @('-X', '--method')) { + if ($index + 1 -lt $arguments.Count) { + $explicitMethod = ([string]$arguments[$index + 1]).ToUpperInvariant() + } + continue + } + + if ($argument -match '^(?:-X|--method)=(.+)$') { + $explicitMethod = $Matches[1].ToUpperInvariant() + continue + } + + if ($argument -in @('-f', '--raw-field', '-F', '--field', '--input')) { + $hasWriteField = $true + } + } + + $isApiWrite = $isCommentEndpoint -and ( + $explicitMethod -in @('POST', 'PUT', 'PATCH') -or + ($null -eq $explicitMethod -and $hasWriteField) + ) + + if ($isApiWrite) { + for ($index = $commandOffset + 1; $index -lt $arguments.Count; $index++) { + $argument = [string]$arguments[$index] + + if ($argument -in @('-f', '--raw-field', '-F', '--field')) { + if ($index + 1 -ge $arguments.Count) { + continue + } + + $field = [string]$arguments[$index + 1] + if ($field -notmatch '^body=(.*)$') { + $index++ + continue + } + + $fieldValue = $Matches[1] + if ($argument -in @('-F', '--field') -and $fieldValue.StartsWith('@')) { + $file = New-CopilotPrefixedBodyFile -Path $fieldValue.Substring(1) + $arguments[$index + 1] = "body=@$($file.Path)" + if ($null -ne $file.TemporaryPath) { + $TemporaryPaths.Add($file.TemporaryPath) + } + } + else { + $arguments[$index + 1] = "body=$(Add-CopilotCommentPrefix -Body $fieldValue)" + } + + $index++ + continue + } + + if ($argument -eq '--input') { + if ($index + 1 -ge $arguments.Count) { + continue + } + + $file = New-CopilotPrefixedJsonFile -Path ([string]$arguments[$index + 1]) + $arguments[$index + 1] = $file.Path + if ($null -ne $file.TemporaryPath) { + $TemporaryPaths.Add($file.TemporaryPath) + } + + $index++ + } + } + } + } + + return [pscustomobject]@{ + Arguments = $arguments.ToArray() + } +} + +if ($null -ne $script:CopilotRealGhPath) { + function gh { + $originalArguments = @($args) + $temporaryPaths = [System.Collections.Generic.List[string]]::new() + try { + $converted = ConvertTo-CopilotPrefixedGhArguments -GhArguments $originalArguments -TemporaryPaths $temporaryPaths + } + catch { + $converted = [pscustomobject]@{ + Arguments = $originalArguments + } + } + + $invokeArguments = @($converted.Arguments) + try { + & $script:CopilotRealGhPath @invokeArguments + $exitCode = $LASTEXITCODE + } + finally { + foreach ($temporaryPath in $temporaryPaths) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + } + } + + $global:LASTEXITCODE = $exitCode + } +} diff --git a/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.py b/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.py new file mode 100755 index 00000000000..9e0a4076940 --- /dev/null +++ b/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + + +TARGET_SLUG = "dotnet/msbuild" +COMMENT_PREFIX = "> [!NOTE]\n> This comment was generated by GitHub Copilot CLI." + +COMMENT_ENDPOINT = re.compile( + r"(?:" + r"/issues/\d+/comments|" + r"/issues/comments/\d+|" + r"/pulls/\d+/(?:comments|reviews)|" + r"/pulls/comments/\d+|" + r"/comments/\d+/replies|" + r"/reviews/\d+/comments" + r")", + re.IGNORECASE, +) +API_REPOSITORY = re.compile( + r"(?:^|/)repos/([^/]+)/([^/?#]+)(?:/|$)", re.IGNORECASE +) +GITHUB_URL_PREFIX = re.compile( + r"^(?:https?://github\.com/|git@github\.com:|github\.com/)", re.IGNORECASE +) +OPTIONS_WITH_VALUES = { + "-F", + "-H", + "-R", + "-X", + "-b", + "-f", + "-q", + "-t", + "--body", + "--body-file", + "--cache", + "--field", + "--header", + "--hostname", + "--input", + "--jq", + "--method", + "--raw-field", + "--repo", + "--template", +} + + +def add_prefix(body: str) -> str: + if body.replace("\r\n", "\n").startswith(COMMENT_PREFIX): + return body + return f"{COMMENT_PREFIX}\n\n{body}" + + +def normalize_repository(value: str) -> str | None: + candidate = value.strip() + prefixes = ( + "https://github.com/", + "http://github.com/", + "git@github.com:", + "github.com/", + "https://api.github.com/", + "http://api.github.com/", + ) + for prefix in prefixes: + if candidate.casefold().startswith(prefix): + candidate = candidate[len(prefix) :] + break + + candidate = candidate.strip("/") + if candidate.casefold().startswith("repos/"): + candidate = candidate[len("repos/") :] + + parts = candidate.split("/") + if len(parts) < 2 or not parts[0] or not parts[1]: + return None + + repository = parts[1].split("?", 1)[0].split("#", 1)[0] + if repository.casefold().endswith(".git"): + repository = repository[:-4] + return f"{parts[0]}/{repository}".casefold() + + +def explicit_repository(arguments: list[str]) -> tuple[bool, str | None]: + index = 0 + while index < len(arguments): + argument = arguments[index] + if argument in {"-R", "--repo"}: + if index + 1 >= len(arguments): + return True, None + return True, normalize_repository(arguments[index + 1]) + if argument.startswith("--repo="): + return True, normalize_repository(argument[len("--repo=") :]) + if argument.startswith("-R") and len(argument) > 2: + return True, normalize_repository(argument[2:]) + + if argument in OPTIONS_WITH_VALUES and index + 1 < len(arguments): + index += 2 + else: + index += 1 + + return False, None + + +def command_offset(arguments: list[str]) -> int | None: + index = 0 + while index < len(arguments): + argument = arguments[index] + if argument in {"-R", "--repo", "--hostname"}: + if index + 1 >= len(arguments): + return None + index += 2 + continue + if argument.startswith(("--repo=", "--hostname=")): + index += 1 + continue + if argument.startswith("-R") and len(argument) > 2: + index += 1 + continue + return index + return None + + +def comment_target_repository( + arguments: list[str], offset: int +) -> str | None: + target_index = offset + 2 + if target_index >= len(arguments): + return None + + target = arguments[target_index] + if target.startswith("-") or not GITHUB_URL_PREFIX.search(target): + return None + return normalize_repository(target) + + +def api_endpoint(arguments: list[str], offset: int) -> str | None: + index = offset + 1 + while index < len(arguments): + argument = arguments[index] + if argument in OPTIONS_WITH_VALUES: + index += 2 + continue + if argument.startswith("-"): + index += 1 + continue + return argument + return None + + +def api_target_repository(endpoint: str) -> str | None: + match = API_REPOSITORY.search(endpoint) + if match is None: + return None + + owner = match.group(1) + repository = match.group(2) + if owner == "{owner}" and repository == "{repo}": + return TARGET_SLUG + return f"{owner}/{repository}".casefold() + + +def targets_msbuild(arguments: list[str]) -> bool: + has_explicit_repository, repository = explicit_repository(arguments) + if has_explicit_repository: + return repository == TARGET_SLUG + + offset = command_offset(arguments) + if offset is None: + return False + + if ( + offset + 1 < len(arguments) + and arguments[offset] in {"issue", "pr"} + and arguments[offset + 1] == "comment" + ) or ( + offset + 1 < len(arguments) + and arguments[offset] == "pr" + and arguments[offset + 1] == "review" + ): + repository = comment_target_repository(arguments, offset) + return repository is None or repository == TARGET_SLUG + + if arguments[offset] == "api": + endpoint = api_endpoint(arguments, offset) + if endpoint is None: + return False + repository = api_target_repository(endpoint) + return repository is None or repository == TARGET_SLUG + + return False + + +def new_temp_file(suffix: str, content: str, temporary_paths: list[Path]) -> Path: + descriptor, path = tempfile.mkstemp( + prefix="copilot-gh-comment-", suffix=suffix, dir=os.environ.get("TMPDIR") + ) + temporary_path = Path(path) + temporary_paths.append(temporary_path) + with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream: + stream.write(content) + return temporary_path + + +def prefixed_body_file(path: str, temporary_paths: list[Path]) -> str: + if path == "-": + return path + + source_path = Path(path).expanduser() + if not source_path.is_file(): + return path + + try: + body = source_path.read_text(encoding="utf-8") + prefixed_body = add_prefix(body) + except (OSError, UnicodeError): + return path + + if prefixed_body == body: + return path + return str(new_temp_file(".md", prefixed_body, temporary_paths)) + + +def prefixed_json_file(path: str, temporary_paths: list[Path]) -> str: + if path == "-": + return path + + source_path = Path(path).expanduser() + if not source_path.is_file(): + return path + + try: + payload = json.loads(source_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return path + + if not isinstance(payload, dict) or not isinstance(payload.get("body"), str): + return path + + prefixed_body = add_prefix(payload["body"]) + if prefixed_body == payload["body"]: + return path + + payload["body"] = prefixed_body + content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return str(new_temp_file(".json", content, temporary_paths)) + + +def transform_comment_args( + arguments: list[str], temporary_paths: list[Path] +) -> list[str]: + transformed = list(arguments) + offset = command_offset(transformed) + if offset is None or offset + 1 >= len(transformed): + return transformed + + is_comment = ( + transformed[offset] in {"issue", "pr"} + and transformed[offset + 1] == "comment" + ) + is_review = ( + transformed[offset] == "pr" and transformed[offset + 1] == "review" + ) + if not (is_comment or is_review): + return transformed + + index = offset + 2 + while index < len(transformed): + argument = transformed[index] + + if argument in {"--body", "-b"} and index + 1 < len(transformed): + transformed[index + 1] = add_prefix(transformed[index + 1]) + index += 2 + continue + + if argument.startswith("--body="): + transformed[index] = f"--body={add_prefix(argument[len('--body='):])}" + index += 1 + continue + + if argument in {"--body-file", "-F"} and index + 1 < len(transformed): + transformed[index + 1] = prefixed_body_file( + transformed[index + 1], temporary_paths + ) + index += 2 + continue + + if argument.startswith("--body-file="): + path = argument[len("--body-file=") :] + transformed[index] = ( + f"--body-file={prefixed_body_file(path, temporary_paths)}" + ) + + index += 1 + + return transformed + + +def api_write_details(arguments: list[str]) -> tuple[int | None, bool]: + offset = command_offset(arguments) + if offset is None or arguments[offset] != "api": + return None, False + + endpoint = api_endpoint(arguments, offset) + if endpoint is None or not COMMENT_ENDPOINT.search(endpoint): + return None, False + + explicit_method: str | None = None + has_write_field = False + index = offset + 1 + + while index < len(arguments): + argument = arguments[index] + if argument in {"-X", "--method"} and index + 1 < len(arguments): + explicit_method = arguments[index + 1].upper() + index += 2 + continue + if argument.startswith("--method="): + explicit_method = argument[len("--method=") :].upper() + elif argument.startswith("-X") and len(argument) > 2: + explicit_method = argument[2:].upper() + + if argument in {"-f", "--raw-field", "-F", "--field", "--input"}: + has_write_field = True + elif argument.startswith(("--raw-field=", "--field=", "--input=")): + has_write_field = True + + index += 1 + + is_write = explicit_method in {"POST", "PUT", "PATCH"} or ( + explicit_method is None and has_write_field + ) + return offset, is_write + + +def transform_api_args( + arguments: list[str], temporary_paths: list[Path] +) -> list[str]: + offset, is_write = api_write_details(arguments) + if offset is None or not is_write: + return list(arguments) + + transformed = list(arguments) + index = offset + 1 + + while index < len(transformed): + argument = transformed[index] + + if ( + argument in {"-f", "--raw-field", "-F", "--field"} + and index + 1 < len(transformed) + ): + field = transformed[index + 1] + if field.startswith("body="): + value = field[len("body=") :] + if argument in {"-F", "--field"} and value.startswith("@"): + value = f"@{prefixed_body_file(value[1:], temporary_paths)}" + else: + value = add_prefix(value) + transformed[index + 1] = f"body={value}" + index += 2 + continue + + field_prefixes = { + "--raw-field=": False, + "--field=": True, + } + matched_field = False + for prefix, supports_file in field_prefixes.items(): + if not argument.startswith(prefix): + continue + field = argument[len(prefix) :] + if not field.startswith("body="): + break + value = field[len("body=") :] + if supports_file and value.startswith("@"): + value = f"@{prefixed_body_file(value[1:], temporary_paths)}" + else: + value = add_prefix(value) + transformed[index] = f"{prefix}body={value}" + matched_field = True + break + if matched_field: + index += 1 + continue + + if argument == "--input" and index + 1 < len(transformed): + transformed[index + 1] = prefixed_json_file( + transformed[index + 1], temporary_paths + ) + index += 2 + continue + + if argument.startswith("--input="): + path = argument[len("--input=") :] + transformed[index] = ( + f"--input={prefixed_json_file(path, temporary_paths)}" + ) + + index += 1 + + return transformed + + +def transform_arguments( + arguments: list[str], temporary_paths: list[Path] +) -> list[str]: + if not targets_msbuild(arguments): + return list(arguments) + + transformed = transform_comment_args(arguments, temporary_paths) + return transform_api_args(transformed, temporary_paths) + + +def run_gh(real_gh: str, arguments: list[str]) -> int: + completed = subprocess.run([real_gh, *arguments], check=False) + if completed.returncode < 0: + return 128 - completed.returncode + return completed.returncode + + +def main() -> int: + if len(sys.argv) < 2: + return 127 + + real_gh = sys.argv[1] + original_arguments = sys.argv[2:] + temporary_paths: list[Path] = [] + + try: + try: + arguments = transform_arguments(original_arguments, temporary_paths) + except Exception: + arguments = original_arguments + return run_gh(real_gh, arguments) + finally: + for temporary_path in temporary_paths: + try: + temporary_path.unlink(missing_ok=True) + except OSError: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.sh b/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.sh new file mode 100755 index 00000000000..fc1993e9f5b --- /dev/null +++ b/.github/hooks/copilot-cli-comment-prefix/gh-comment-wrapper.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +_COPILOT_GH_HOOK_DIR="$( + cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && + pwd -P +)" +_COPILOT_GH_REAL="$(type -P gh 2>/dev/null || true)" +_COPILOT_GH_PYTHON="$(command -v python3 2>/dev/null || true)" +_COPILOT_GH_WRAPPER="$_COPILOT_GH_HOOK_DIR/gh-comment-wrapper.py" + +if [[ -n "$_COPILOT_GH_REAL" && -n "$_COPILOT_GH_PYTHON" && -f "$_COPILOT_GH_WRAPPER" ]]; then + gh() { + "$_COPILOT_GH_PYTHON" "$_COPILOT_GH_WRAPPER" "$_COPILOT_GH_REAL" "$@" + } +fi diff --git a/.github/hooks/copilot-cli-comment-prefix/prefix-github-comments.ps1 b/.github/hooks/copilot-cli-comment-prefix/prefix-github-comments.ps1 new file mode 100644 index 00000000000..ac0c5ea7695 --- /dev/null +++ b/.github/hooks/copilot-cli-comment-prefix/prefix-github-comments.ps1 @@ -0,0 +1,190 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$script:CommentPrefix = "> [!NOTE]`n> This comment was generated by GitHub Copilot CLI." + +trap { + [Console]::Out.WriteLine('{}') + exit 0 +} + +function Write-HookOutput { + param([Parameter(Mandatory)][object] $Value) + + $Value | ConvertTo-Json -Compress -Depth 100 + exit 0 +} + +function Add-CommentPrefix { + param([AllowEmptyString()][string] $Body) + + $normalizedBody = $Body.Replace("`r`n", "`n") + if ($normalizedBody.StartsWith($script:CommentPrefix, [System.StringComparison]::Ordinal)) { + return $Body + } + + return "$script:CommentPrefix`n`n$Body" +} + +function Get-NormalizedRepository { + param( + [AllowNull()][object] $Owner, + [AllowNull()][object] $Repository + ) + + if ($Repository -isnot [string]) { + return $null + } + + $repositoryValue = $Repository.Trim().Trim('/') + if ($repositoryValue.Contains('/')) { + return ($repositoryValue -replace '(?i)\.git$', '').ToLowerInvariant() + } + + if ($Owner -isnot [string] -or [string]::IsNullOrWhiteSpace($Owner)) { + return $null + } + + return "$($Owner.Trim())/$repositoryValue".ToLowerInvariant() +} + +function Test-TargetsMsbuild { + param([Parameter(Mandatory)][object] $ToolArgs) + + $ownerProperty = $ToolArgs.PSObject.Properties['owner'] + $repoProperty = $ToolArgs.PSObject.Properties['repo'] + $repositoryProperty = $ToolArgs.PSObject.Properties['repository'] + + $owner = if ($null -ne $ownerProperty) { $ownerProperty.Value } else { $null } + $repository = if ($null -ne $repoProperty) { + $repoProperty.Value + } + elseif ($null -ne $repositoryProperty) { + $repositoryProperty.Value + } + else { + $null + } + + return (Get-NormalizedRepository -Owner $owner -Repository $repository) -ceq 'dotnet/msbuild' +} + +function Test-GhCommentWriteCommand { + param([Parameter(Mandatory)][string] $Command) + + $ghCommandPrefix = '(? [!NOTE]\n> This comment was generated by GitHub Copilot CLI." + +MCP_COMMENT_TOOL = re.compile( + r"(?:^|[-_])(?:" + r"add_issue_comment|" + r"add_reply_to_pull_request_comment|" + r"add_comment_to_pending_review|" + r"add_pull_request_review_comment|" + r"create_pull_request_review|" + r"submit_pending_pull_request_review|" + r"pull_request_review_write|" + r"discussion_comment_write" + r")$" +) +GH_COMMAND_PREFIX = ( + r"(? None: + json.dump(value, sys.stdout, ensure_ascii=False, separators=(",", ":")) + sys.stdout.write("\n") + + +def add_prefix(body: str) -> str: + if body.replace("\r\n", "\n").startswith(COMMENT_PREFIX): + return body + return f"{COMMENT_PREFIX}\n\n{body}" + + +def normalized_repository(owner: Any, repository: Any) -> str | None: + if not isinstance(repository, str): + return None + + repository = repository.strip().strip("/") + if "/" in repository: + if repository.casefold().endswith(".git"): + repository = repository[:-4] + return repository.casefold() + if not isinstance(owner, str) or not owner.strip(): + return None + return f"{owner.strip()}/{repository}".casefold() + + +def targets_msbuild(tool_args: dict[str, Any]) -> bool: + repository = normalized_repository(tool_args.get("owner"), tool_args.get("repo")) + if repository is None: + repository = normalized_repository( + tool_args.get("owner"), tool_args.get("repository") + ) + return repository == TARGET_SLUG + + +def is_gh_comment_write(command: str) -> bool: + if GH_COMMENT_COMMAND.search(command): + return True + if GH_REVIEW_COMMAND.search(command) and GH_REVIEW_BODY.search(command): + return True + return bool( + GH_API_COMMAND.search(command) + and GH_COMMENT_ENDPOINT.search(command) + and GH_API_WRITE.search(command) + ) + + +def rewrite_mcp_args(tool_args: dict[str, Any]) -> bool: + changed = False + + body = tool_args.get("body") + if isinstance(body, str): + prefixed_body = add_prefix(body) + if prefixed_body != body: + tool_args["body"] = prefixed_body + changed = True + + comments = tool_args.get("comments") + if isinstance(comments, list): + for comment in comments: + if not isinstance(comment, dict): + continue + comment_body = comment.get("body") + if not isinstance(comment_body, str): + continue + prefixed_body = add_prefix(comment_body) + if prefixed_body != comment_body: + comment["body"] = prefixed_body + changed = True + + return changed + + +def main() -> None: + hook_input = json.load(sys.stdin) + tool_name = str(hook_input.get("toolName", "")) + tool_args = hook_input.get("toolArgs") + + if isinstance(tool_args, str): + tool_args = json.loads(tool_args) + if not isinstance(tool_args, dict): + emit({}) + return + + if MCP_COMMENT_TOOL.search(tool_name): + if not targets_msbuild(tool_args): + emit({}) + return + emit({"modifiedArgs": tool_args} if rewrite_mcp_args(tool_args) else {}) + return + + if tool_name != "bash": + emit({}) + return + + command = tool_args.get("command") + if not isinstance(command, str) or not is_gh_comment_write(command): + emit({}) + return + + if GH_GRAPHQL_COMMENT.search(command): + emit({}) + return + + wrapper_path = Path(__file__).with_name("gh-comment-wrapper.sh") + if not wrapper_path.is_file(): + emit({}) + return + + tool_args["command"] = f". {shlex.quote(str(wrapper_path))}; {command}" + emit({"modifiedArgs": tool_args}) + + +if __name__ == "__main__": + try: + main() + except Exception: + emit({})