Skip to content
101 changes: 101 additions & 0 deletions .github/skills/review-external-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <PR_NUMBER> --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/<helpful-name>-original-pr-<number>`

```bash
git fetch origin
git checkout -b reviews/<helpful-name>-original-pr-<PR_NUMBER> origin/next
git push origin reviews/<helpful-name>-original-pr-<PR_NUMBER>
```

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 <PR_NUMBER> --json baseRefName`.

```bash
gh pr edit <PR_NUMBER> --base reviews/<helpful-name>-original-pr-<PR_NUMBER>
```

Verify:

```bash
gh pr view <PR_NUMBER> --json baseRefName
```

### 4. Merge the Contributor's PR

Once the base is updated and the PR is ready:

```bash
gh pr merge <PR_NUMBER> --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/<helpful-name>-original-pr-<PR_NUMBER>
git pull origin reviews/<helpful-name>-original-pr-<PR_NUMBER>
```

Create the PR:

```bash
gh pr create \
--base next \
--head reviews/<helpful-name>-original-pr-<PR_NUMBER> \
--title "<original title> [reviewed]" \
--body "This PR finalizes the review of the contribution originally submitted by @<author_login> in #<PR_NUMBER>.

Original PR: <PR_URL>"
```

### 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 <ORIGINAL_PR_NUMBER> \
--body "Thank you for the contribution! The review is continuing in #<NEW_PR_NUMBER> 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 |
9 changes: 9 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
28 changes: 28 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [],
Expand Down Expand Up @@ -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"
Expand Down
153 changes: 153 additions & 0 deletions src/commands/copyReference/copyReference.ts
Original file line number Diff line number Diff line change
@@ -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, number | string>): 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<void> {
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'));
}
6 changes: 6 additions & 0 deletions src/documentdb/ClustersExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
Loading