Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,14 @@
"default": false,
"description": "%githubPullRequests.deleteBranchAfterMerge.description%"
},
"githubPullRequests.enableAttestationCommits": {
"type": [
"boolean",
"string"
],
"default": false,
"markdownDescription": "%githubPullRequests.enableAttestationCommits.description%"
},
"githubPullRequests.terminalLinksHandler": {
"type": "string",
"enum": [
Expand Down Expand Up @@ -1546,6 +1554,11 @@
"title": "%command.review.approveOnDotCom.title%",
"category": "%command.pull.request.category%"
},
{
"command": "pr.addAttestationCommit",
"title": "%command.pr.addAttestationCommit.title%",
"category": "%command.pull.request.category%"
},
{
"command": "review.requestChangesOnDotCom",
"title": "%command.review.requestChangesOnDotCom.title%",
Expand Down Expand Up @@ -2084,6 +2097,10 @@
"command": "github.api.preloadPullRequest",
"when": "false"
},
{
"command": "pr.addAttestationCommit",
"when": "config.githubPullRequests.enableAttestationCommits"
},
{
"command": "pr.configureRemotes",
"when": "gitHubOpenRepositoryCount != 0"
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"githubPullRequests.defaultDeletionMethod.selectRemote.description": "When true, the option to delete the remote will be selected by default when deleting a branch from a pull request.",
"githubPullRequests.defaultDeletionMethod.selectWorktree.description": "When true, the option to remove the associated worktree will be selected by default when deleting a branch from a pull request.",
"githubPullRequests.deleteBranchAfterMerge.description": "Automatically delete the branch after merging a pull request. This setting only applies when the pull request is merged through this extension. When using merge queues, this will only delete the local branch.",
"githubPullRequests.enableAttestationCommits.description": "Enables adding an attestation commit (an empty, signed commit) to the head of a pull request branch as a way to attest to a pull request even when its individual commits are unsigned. Requires commit signing to be configured for git. Set to `true` to enable with the default commit message, set to a string to use that string as the commit message, or set to `false` to disable.",
"githubPullRequests.terminalLinksHandler.description": "Default handler for terminal links.",
"githubPullRequests.terminalLinksHandler.github": "Create the pull request on GitHub.",
"githubPullRequests.terminalLinksHandler.vscode": "Create the pull request in VS Code.",
Expand Down Expand Up @@ -246,6 +247,7 @@
"command.review.comment.title": "Comment",
"command.review.requestChanges.title": "Request Changes",
"command.review.approveOnDotCom.title": "Approve on github.com",
"command.pr.addAttestationCommit.title": "Add Attestation Commit",
"command.review.requestChangesOnDotCom.title": "Request changes on github.com",
"command.review.createSuggestionsFromChanges.title": "Create Pull Request Suggestions",
"command.review.createSuggestionFromChange.title": "Convert to Pull Request Suggestion",
Expand Down
46 changes: 46 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SessionLinkInfo } from './common/timelineEvent';
import { asTempStorageURI, fromPRUri, fromReviewUri, Schemes, toPRUri } from './common/uri';
import { formatError } from './common/utils';
import { EXTENSION_ID } from './constants';
import { addAttestationCommit } from './github/attestationCommit';
import { CrossChatSessionWithPR } from './github/copilotApi';
import { CopilotRemoteAgentManager, SessionIdForPr } from './github/copilotRemoteAgent';
import { guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './github/fileUpload';
Expand Down Expand Up @@ -218,6 +219,51 @@ export function registerCommands(
),
);

context.subscriptions.push(
vscode.commands.registerCommand(
'pr.addAttestationCommit',
async (target?: PullRequestModel | PRNode | RepositoryChangesNode) => {
let pr: PullRequestModel | undefined;
let folderManager: FolderRepositoryManager | undefined;

if (target instanceof PullRequestModel) {
pr = target;
} else if (target && (target as PRNode).pullRequestModel) {
pr = (target as PRNode).pullRequestModel;
} else if (target && (target as RepositoryChangesNode).pullRequestModel) {
pr = (target as RepositoryChangesNode).pullRequestModel;
}

if (pr) {
folderManager = reposManager.getManagerForIssueModel(pr) ?? reposManager.folderManagers.find(m => m.activePullRequest?.equals(pr!));
}

if (!pr || !folderManager) {
const activePullRequestsWithFolderManager = reposManager.folderManagers
.filter(m => m.activePullRequest)
.map(m => ({ activePr: m.activePullRequest!, folderManager: m }));

if (activePullRequestsWithFolderManager.length === 0) {
vscode.window.showErrorMessage(vscode.l10n.t('No active pull request to add an attestation commit to.'));
return;
}

const picked = activePullRequestsWithFolderManager.length === 1
? activePullRequestsWithFolderManager[0]
: await chooseItem(activePullRequestsWithFolderManager, item => ({ label: item.activePr.html_url }));

if (!picked) {
return;
}
pr = picked.activePr;
folderManager = picked.folderManager;
}

await addAttestationCommit(folderManager, pr);
},
),
);

context.subscriptions.push(
vscode.commands.registerCommand('pr.openFileOnGitHub', async (e: GitFileChangeNode | RemoteFileChangeNode) => {
if (e instanceof RemoteFileChangeNode) {
Expand Down
1 change: 1 addition & 0 deletions src/common/settingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const SELECT_LOCAL_BRANCH = 'selectLocalBranch';
export const SELECT_REMOTE = 'selectRemote';
export const SELECT_WORKTREE = 'selectWorktree';
export const DELETE_BRANCH_AFTER_MERGE = 'deleteBranchAfterMerge';
export const ENABLE_ATTESTATION_COMMITS = 'enableAttestationCommits';
export const REMOTES = 'remotes';
export const PULL_PR_BRANCH_BEFORE_CHECKOUT = 'pullPullRequestBranchBeforeCheckout';
export type PullPRBranchVariants = 'never' | 'pull' | 'pullAndMergeBase' | 'pullAndUpdateBase' | true | false;
Expand Down
53 changes: 42 additions & 11 deletions src/github/activityBarViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@

import * as vscode from 'vscode';
import { openPullRequestOnGitHub } from '../commands';
import { addAttestationCommit, isAttestationCommitsEnabled } from './attestationCommit';
import { FolderRepositoryManager } from './folderRepositoryManager';
import { GithubItemStateEnum, IAccount, MergeMethod, ReviewEventEnum, ReviewState } from './interface';
import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel';
import { getDefaultMergeMethod } from './pullRequestOverview';
import { PullRequestReviewCommon, ReviewContext } from './pullRequestReviewCommon';
import { isInCodespaces, parseReviewers } from './utils';
import { MergeArguments, PullRequest, ReviewType } from './views';
import { MergeArguments, PullRequest, ReviewType, SubmitReviewArgs } from './views';
import { IComment } from '../common/comment';
import { emojify, ensureEmojis } from '../common/emoji';
import { disposeAll } from '../common/lifecycle';
Expand Down Expand Up @@ -108,6 +109,8 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W
return this.updateBranch(message);
case 'pr.re-request-review':
return this.reRequestReview(message);
case 'pr.add-attestation-commit':
return this.addAttestationCommitMessage(message);
}
}

Expand All @@ -119,6 +122,11 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W
return PullRequestReviewCommon.reRequestReview(this.getReviewContext(), message);
}

private async addAttestationCommitMessage(message: IRequestMessage<void>): Promise<void> {
const sha = await addAttestationCommit(this._folderRepositoryManager, this._item);
this._replyMessage(message, { success: !!sha, sha });
}
Comment thread
Copilot marked this conversation as resolved.

public async refresh(): Promise<void> {
return vscode.window.withProgress({ location: { viewId: 'github:activePullRequest' } }, async () => {
await this._item.initializeReviewThreadCache();
Expand Down Expand Up @@ -302,7 +310,8 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W
isEnterprise: pullRequest.githubRepository.remote.isEnterprise,
hasReviewDraft,
currentUserReviewState: reviewState,
isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors)
isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors),
attestationCommitsEnabled: isAttestationCommitsEnabled()
};

this._postMessage({
Expand Down Expand Up @@ -348,21 +357,43 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W
);
}

private async doReviewMessage(message: IRequestMessage<string>, action: (body) => Promise<ReviewEvent>) {
private async doReviewMessage(message: IRequestMessage<string>, action: (body) => Promise<ReviewEvent>, needsTimelineRefresh: boolean = false) {
return PullRequestReviewCommon.doReviewMessage(
this.getReviewContext(),
message,
false,
action
needsTimelineRefresh,
action,
);
}

/**
* Handles a review-submission message that may include a request to first
* push a signed attestation commit. If attestation fails, the review is
* NOT submitted.
*/
private async doReviewMessageWithAttestation(
message: IRequestMessage<SubmitReviewArgs>,
action: (body: string) => Promise<ReviewEvent>,
): Promise<void> {
const { body, addAttestation } = message.args ?? { body: '' };
let attestationSha: string | undefined;
if (addAttestation) {
attestationSha = await addAttestationCommit(this._folderRepositoryManager, this._item);
if (!attestationSha) {
this._throwError(message, 'Attestation commit failed; review was not submitted.');
return;
}
}
const forwarded: IRequestMessage<string> = { req: message.req, command: message.command, args: body };
await this.doReviewMessage(forwarded, action, !!attestationSha);
}

private approvePullRequest(body: string): Promise<ReviewEvent> {
return this._item.approve(this._folderRepositoryManager.repository, body);
}

private async approvePullRequestMessage(message: IRequestMessage<string>): Promise<void> {
await this.doReviewMessage(message, (body) => this.approvePullRequest(body));
private async approvePullRequestMessage(message: IRequestMessage<SubmitReviewArgs>): Promise<void> {
await this.doReviewMessageWithAttestation(message, (body) => this.approvePullRequest(body));
}

private async approvePullRequestCommand(context: { body: string }): Promise<void> {
Expand All @@ -377,8 +408,8 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W
await this.doReviewCommand(context, ReviewType.RequestChanges, (body) => this.requestChanges(body));
}

private async requestChangesMessage(message: IRequestMessage<string>): Promise<void> {
await this.doReviewMessage(message, (body) => this.requestChanges(body));
private async requestChangesMessage(message: IRequestMessage<SubmitReviewArgs>): Promise<void> {
await this.doReviewMessageWithAttestation(message, (body) => this.requestChanges(body));
}

private submitReview(body: string): Promise<ReviewEvent> {
Expand All @@ -389,8 +420,8 @@ export class PullRequestViewProvider extends WebviewViewBase implements vscode.W
return this.doReviewCommand(context, ReviewType.Comment, (body) => this.submitReview(body));
}

private submitReviewMessage(message: IRequestMessage<string>) {
return this.doReviewMessage(message, (body) => this.submitReview(body));
private submitReviewMessage(message: IRequestMessage<SubmitReviewArgs>) {
return this.doReviewMessageWithAttestation(message, (body) => this.submitReview(body));
}

private async deleteBranch(message: IRequestMessage<any>) {
Expand Down
142 changes: 142 additions & 0 deletions src/github/attestationCommit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { FolderRepositoryManager } from './folderRepositoryManager';
import { PullRequestModel } from './pullRequestModel';
import { Repository } from '../api/api';
import Logger from '../common/logger';
import { ENABLE_ATTESTATION_COMMITS, PR_SETTINGS_NAMESPACE } from '../common/settingKeys';
import { formatError } from '../common/utils';

const LOG_ID = 'AttestationCommit';

const DEFAULT_ATTESTATION_COMMIT_MESSAGE = 'Attestation commit';

/**
* Returns true when the repository appears to have commit signing configured.
*
* Accepts any of the following (checked across local + global git config):
* - `user.signingkey` is set, OR
* - `commit.gpgsign` is `true` (git will pick a default signing identity), OR
* - `gpg.format` is set to `ssh` or `x509` (the user is explicitly opting in
* to a non-default signing format).
*/
async function hasCommitSigningConfigured(repository: Repository): Promise<boolean> {
const read = async (key: string): Promise<string | undefined> => {
const tryRead = async (fn: (k: string) => Promise<string>): Promise<string | undefined> => {
try {
const value = await fn(key);
return value?.trim() || undefined;
} catch {
// `getConfig`/`getGlobalConfig` reject when the key is not set.
return undefined;
}
};
return (await tryRead(k => repository.getConfig(k)))
?? (await tryRead(k => repository.getGlobalConfig(k)));
};

const [signingKey, commitGpgSign, gpgFormat] = await Promise.all([
read('user.signingkey'),
read('commit.gpgsign'),
read('gpg.format'),
]);

if (signingKey) {
return true;
}
if (commitGpgSign?.toLowerCase() === 'true') {
return true;
}
if (gpgFormat && ['ssh', 'x509'].includes(gpgFormat.toLowerCase())) {
return true;
}
return false;
}

/**
* Reads the `githubPullRequests.enableAttestationCommits` setting.
* Returns `false` when the feature is disabled, otherwise the commit message
* that should be used for the attestation commit.
*/
export function getAttestationCommitSetting(): false | string {
const value = vscode.workspace
.getConfiguration(PR_SETTINGS_NAMESPACE)
.get<boolean | string>(ENABLE_ATTESTATION_COMMITS, false);

if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) {
return false;
}
return trimmed;
}
return value === true ? DEFAULT_ATTESTATION_COMMIT_MESSAGE : false;
}

/**
* Whether the attestation commit feature is enabled by the user setting.
*/
export function isAttestationCommitsEnabled(): boolean {
return getAttestationCommitSetting() !== false;
}

/**
* Adds an empty, signed "attestation" commit to the head of the given pull request branch
* and pushes it to the corresponding remote. Requires that the pull request is currently
* checked out and that the user has commit signing configured.
*
* Returns the SHA of the new attestation commit when successful, otherwise `undefined`.
*/
export async function addAttestationCommit(
folderRepositoryManager: FolderRepositoryManager,
pullRequestModel: PullRequestModel,
): Promise<string | undefined> {
const message = getAttestationCommitSetting();
if (message === false) {
vscode.window.showWarningMessage(vscode.l10n.t('Attestation commits are not enabled. Enable them via the `githubPullRequests.enableAttestationCommits` setting.'));
return undefined;
}

const activePullRequest = folderRepositoryManager.activePullRequest;
if (!activePullRequest || !activePullRequest.equals(pullRequestModel)) {
vscode.window.showErrorMessage(vscode.l10n.t('The pull request must be checked out before an attestation commit can be added.'));
return undefined;
}

const repository = folderRepositoryManager.repository;
const head = repository.state.HEAD;
if (!head || !head.name) {
vscode.window.showErrorMessage(vscode.l10n.t('Unable to add an attestation commit: no branch is currently checked out.'));
return undefined;
}

if (!await hasCommitSigningConfigured(repository)) {
vscode.window.showErrorMessage(vscode.l10n.t('Unable to add an attestation commit: commit signing does not appear to be configured. Set `user.signingkey` (or enable `commit.gpgsign`) in your git config and ensure your signing tool (GPG, SSH, or X.509) is set up.'));
return undefined;
}

try {
Logger.appendLine(`Creating attestation commit on branch ${head.name} for PR #${pullRequestModel.number}`, LOG_ID);
await repository.commit(message, {
empty: true,
signCommit: true,
});

const upstream = head.upstream;
if (upstream) {
await repository.push(upstream.remote, head.name);
} else {
await repository.push();
}

return repository.state.HEAD?.commit;
} catch (e) {
Logger.error(`Failed to add attestation commit: ${formatError(e)}`, LOG_ID);
vscode.window.showErrorMessage(vscode.l10n.t('Failed to add attestation commit: {0}', formatError(e)));
return undefined;
}
}
Loading