diff --git a/.github/skills/review-external-pr/SKILL.md b/.github/skills/review-external-pr/SKILL.md new file mode 100644 index 000000000..6a4cc134b --- /dev/null +++ b/.github/skills/review-external-pr/SKILL.md @@ -0,0 +1,101 @@ +--- +name: review-external-pr +description: Prepare an external contributor's PR for maintainer review by redirecting it into a dedicated review branch, then merging and creating a new finalization PR targeting next. Use when triaging/reviewing contributor PRs, merging external PRs with maintainer changes, or setting up a review workflow for incoming community contributions. +--- + +# Review External PR Workflow + +Redirects an external contributor's PR into a `reviews/` staging branch so a maintainer can inspect, add changes, then merge everything into `next` cleanly. + +## When to Use + +- An external contributor opened a PR targeting `next` and you want to add changes before merging +- You want to formally review and finalize a community contribution +- You want the contributor to get proper merge credit while still controlling what lands in `next` + +## Workflow Steps + +### 1. Gather PR Info + +```bash +gh pr view --json title,author,headRefName,baseRefName,body +``` + +Note the **PR number**, **title**, and **author login** — you'll need them for branch naming and PR descriptions. + +### 2. Create the Review Branch + +Branch naming format: `reviews/-original-pr-` + +```bash +git fetch origin +git checkout -b reviews/-original-pr- origin/next +git push origin reviews/-original-pr- +``` + +Example: `reviews/copy-reference-original-pr-545` + +### 3. Retarget the Contributor's PR + +> ⚠️ **Known issue**: `gh pr edit --base` may emit a deprecation warning about Projects (classic). This is a cosmetic warning only — the base branch change succeeds regardless. Verify with `gh pr view --json baseRefName`. + +```bash +gh pr edit --base reviews/-original-pr- +``` + +Verify: + +```bash +gh pr view --json baseRefName +``` + +### 4. Merge the Contributor's PR + +Once the base is updated and the PR is ready: + +```bash +gh pr merge --squash +``` + +Or approve + merge via the GitHub UI to trigger any required status checks. + +### 5. Create the Finalization PR + +Pull the merged review branch, then open a new PR from it to `next`: + +```bash +git checkout reviews/-original-pr- +git pull origin reviews/-original-pr- +``` + +Create the PR: + +```bash +gh pr create \ + --base next \ + --head reviews/-original-pr- \ + --title " [reviewed]" \ + --body "This PR finalizes the review of the contribution originally submitted by @ in #. + +Original PR: " +``` + +### 6. Comment on the Original PR + +Go back to the contributor's original (now merged) PR and leave a comment linking to the finalization PR: + +```bash +gh pr comment \ + --body "Thank you for the contribution! The review is continuing in # where maintainer changes will be finalized before merging to \`next\`." +``` + +## Summary + +| Step | Action | Result | +| ---- | ------------------------------------------ | ----------------------------------------- | +| 1 | Gather PR info | Know PR number, title, author | +| 2 | Create `reviews/...` branch off `next` | Staging branch ready | +| 3 | Retarget contributor's PR to review branch | Their diff is scoped to review branch | +| 4 | Merge contributor's PR | Contributor gets merge credit | +| 5 | Create finalization PR to `next` | Maintainer controls what lands in `next` | +| 6 | Comment on original PR with link to new PR | Contributor is informed, thread is linked | diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a4493f78a..ee9aae2b7 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -282,12 +282,15 @@ "Connection: {connectionName}": "Connection: {connectionName}", "Connections have moved": "Connections have moved", "Continue": "Continue", + "Copied to clipboard": "Copied to clipboard", "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"": "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"", "Copy index definitions from source collection?": "Copy index definitions from source collection?", "Copy index definitions from source to target collection.": "Copy index definitions from source to target collection.", "Copy Indexes: {yesNoValue}": "Copy Indexes: {yesNoValue}", "Copy only documents without recreating indexes.": "Copy only documents without recreating indexes.", "Copy operation cancelled.": "Copy operation cancelled.", + "Copy Reference: {0}": "Copy Reference: {0}", + "Copy Reference: {0}.{1}": "Copy Reference: {0}.{1}", "Copy with password": "Copy with password", "Copy without password": "Copy without password", "Copy-and-Merge": "Copy-and-Merge", @@ -639,6 +642,7 @@ "JSON View": "JSON View", "Keep-alive timeout exceeded": "Keep-alive timeout exceeded", "Keep-alive timeout exceeded: stream has been running for {0} seconds (limit: {1} seconds)": "Keep-alive timeout exceeded: stream has been running for {0} seconds (limit: {1} seconds)", + "Key Definition": "Key Definition", "Keys Examined": "Keys Examined", "Large Collection Copy Operation": "Large Collection Copy Operation", "Learn more": "Learn more", @@ -695,6 +699,8 @@ "Move to top level": "Move to top level", "Moved {0} item(s) to \"{1}\".": "Moved {0} item(s) to \"{1}\".", "N/A": "N/A", + "Name": "Name", + "Namespace": "Namespace", "New Connection": "New Connection", "New connection has been added to your DocumentDB Connections.": "New connection has been added to your DocumentDB Connections.", "New connection has been added.": "New connection has been added.", @@ -789,6 +795,7 @@ "Project": "Project", "Project: Specify which fields to include or exclude": "Project: Specify which fields to include or exclude", "Provider \"{0}\" does not have resource type \"{1}\".": "Provider \"{0}\" does not have resource type \"{1}\".", + "Qualified Name": "Qualified Name", "Query Efficiency Analysis": "Query Efficiency Analysis", "Query Execution Failed": "Query Execution Failed", "Query generation failed": "Query generation failed", @@ -874,6 +881,8 @@ "SHARD_MERGE · {0} shards": "SHARD_MERGE · {0} shards", "SHARD_MERGE · {0} shards · {1} docs · {2}ms": "SHARD_MERGE · {0} shards · {1} docs · {2}ms", "Shard: {0}": "Shard: {0}", + "Shell Command": "Shell Command", + "Shell Reference": "Shell Reference", "Show Output": "Show Output", "Show Stage Details": "Show Stage Details", "Sign in to additional accounts or authenticate with other tenants to see more options.": "Sign in to additional accounts or authenticate with other tenants to see more options.", diff --git a/package.json b/package.json index ed394f432..ba6272d11 100644 --- a/package.json +++ b/package.json @@ -533,6 +533,12 @@ "category": "DocumentDB", "command": "vscode-documentdb.command.pasteCollection", "title": "Paste Collection…" + }, + { + "//": "Copy Reference", + "category": "DocumentDB", + "command": "vscode-documentdb.command.copyReference", + "title": "Copy Reference…" } ], "submenus": [ @@ -864,6 +870,24 @@ "command": "vscode-documentdb.command.pasteCollection", "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", "group": "3@4" + }, + { + "//": "[Database] Copy Reference", + "command": "vscode-documentdb.command.copyReference", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_database\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "yheAlmostLastGroup@1" + }, + { + "//": "[Collection] Copy Reference", + "command": "vscode-documentdb.command.copyReference", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_collection\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "yheAlmostLastGroup@1" + }, + { + "//": "[Index] Copy Reference", + "command": "vscode-documentdb.command.copyReference", + "when": "view =~ /connectionsView|discoveryView|azure(ResourceGroups|FocusView)/ && viewItem =~ /\\btreeitem_index\\b/i && viewItem =~ /\\bexperience_(documentDB|mongoRU)\\b/i", + "group": "yheAlmostLastGroup@1" } ], "explorer/context": [], @@ -928,6 +952,10 @@ "command": "vscode-documentdb.command.azureResourcesView.addConnectionToConnectionsView", "when": "never" }, + { + "command": "vscode-documentdb.command.copyReference", + "when": "never" + }, { "command": "vscode-documentdb.command.copyConnectionString", "when": "never" diff --git a/src/commands/copyReference/copyReference.ts b/src/commands/copyReference/copyReference.ts new file mode 100644 index 000000000..39b7e74d6 --- /dev/null +++ b/src/commands/copyReference/copyReference.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { CollectionItem } from '../../tree/documentdb/CollectionItem'; +import { type DatabaseItem } from '../../tree/documentdb/DatabaseItem'; +import { IndexItem } from '../../tree/documentdb/IndexItem'; + +interface CopyReferenceOption { + id: string; + label: string; + detail: string; + alwaysShow: true; +} + +function formatIndexKey(key: Record): string { + const entries = Object.entries(key) + .map(([field, order]) => `${field}: ${order}`) + .join(', '); + return `{ ${entries} }`; +} + +/** + * Returns true if a name requires quoting (cannot be used in dot-notation). + * A name is safe for dot-notation only if it matches a valid JS identifier. + */ +function needsQuoting(name: string): boolean { + return !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); +} + +function escapeDoubleQuotes(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function shellCollectionRef(collName: string): string { + return needsQuoting(collName) ? `db.getCollection("${escapeDoubleQuotes(collName)}")` : `db.${collName}`; +} + +function getClusterHost(connectionString: string | undefined): string | undefined { + if (!connectionString) { + return undefined; + } + + try { + const url = new URL(connectionString); + return url.host; + } catch { + return undefined; + } +} + +function opt(label: string, value: string): CopyReferenceOption { + return { id: value, label, detail: value, alwaysShow: true }; +} + +function getDatabaseOptions(node: DatabaseItem): CopyReferenceOption[] { + const dbName = node.databaseInfo.name; + const host = getClusterHost(node.cluster.connectionString); + + const options: CopyReferenceOption[] = [opt(l10n.t('Name'), dbName), opt(l10n.t('Shell Command'), `use ${dbName}`)]; + + if (host) { + options.push(opt(l10n.t('Qualified Name'), `${host}/${dbName}`)); + } + + return options; +} + +function getCollectionOptions(node: CollectionItem): CopyReferenceOption[] { + const dbName = node.databaseInfo.name; + const collName = node.collectionInfo.name; + const quoted = needsQuoting(collName) || needsQuoting(dbName); + const escapedCollName = escapeDoubleQuotes(collName); + + const options: CopyReferenceOption[] = [opt(l10n.t('Name'), collName)]; + + if (!quoted) { + options.push(opt(l10n.t('Namespace'), `${dbName}.${collName}`)); + options.push(opt(l10n.t('Shell Reference'), `db.${collName}`)); + } + + options.push(opt(l10n.t('Shell Command'), `db.getCollection("${escapedCollName}")`)); + + return options; +} + +function getIndexOptions(node: IndexItem): CopyReferenceOption[] { + const indexName = node.indexInfo.name; + const collName = node.collectionInfo.name; + + const options: CopyReferenceOption[] = [opt(l10n.t('Name'), indexName)]; + + if (node.indexInfo.key) { + const keyDef = formatIndexKey(node.indexInfo.key); + const collRef = shellCollectionRef(collName); + const escapedIndexName = escapeDoubleQuotes(indexName); + + options.push(opt(l10n.t('Key Definition'), keyDef)); + options.push( + opt(l10n.t('Shell Command'), `${collRef}.getIndexes().find(i => i.name === "${escapedIndexName}")`), + ); + } + + return options; +} + +function getOptionsForNode(node: DatabaseItem | CollectionItem | IndexItem): { + title: string; + options: CopyReferenceOption[]; +} { + if (node instanceof IndexItem) { + return { + title: l10n.t('Copy Reference: {0}', node.indexInfo.name), + options: getIndexOptions(node), + }; + } + + if (node instanceof CollectionItem) { + return { + title: l10n.t('Copy Reference: {0}.{1}', node.databaseInfo.name, node.collectionInfo.name), + options: getCollectionOptions(node), + }; + } + + return { + title: l10n.t('Copy Reference: {0}', node.databaseInfo.name), + options: getDatabaseOptions(node), + }; +} + +export async function copyReference( + context: IActionContext, + node: DatabaseItem | CollectionItem | IndexItem, +): Promise { + if (!node) { + throw new Error(l10n.t('No node selected.')); + } + + const { title, options } = getOptionsForNode(node); + + const picked = await context.ui.showQuickPick(options, { + placeHolder: title, + stepName: 'copyReference', + suppressPersistence: true, + }); + + await vscode.env.clipboard.writeText(picked.id); + void vscode.window.showInformationMessage(l10n.t('Copied to clipboard')); +} diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index 59f9422e8..6e7108f2d 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -29,6 +29,7 @@ import { renameConnection } from '../commands/connections-view/renameConnection/ import { renameFolder } from '../commands/connections-view/renameFolder/renameFolder'; import { copyCollection } from '../commands/copyCollection/copyCollection'; import { copyAzureConnectionString } from '../commands/copyConnectionString/copyConnectionString'; +import { copyReference } from '../commands/copyReference/copyReference'; import { createCollection } from '../commands/createCollection/createCollection'; import { createAzureDatabase } from '../commands/createDatabase/createDatabase'; import { createMongoDocument } from '../commands/createDocument/createDocument'; @@ -387,6 +388,11 @@ export class ClustersExtension implements vscode.Disposable { withTreeNodeCommandCorrelation(deleteAzureDatabase), ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.copyReference', + withTreeNodeCommandCorrelation(copyReference), + ); + registerCommandWithTreeNodeUnwrapping( 'vscode-documentdb.command.hideIndex', withTreeNodeCommandCorrelation(hideIndex),