Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support setting project when viewing a PR #5341

Merged
merged 3 commits into from
Oct 16, 2023
Merged
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
2 changes: 1 addition & 1 deletion build/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ module.exports.copyrightFilter = [
'!.github/**/*',
'!.husky/**/*',
'!tsfmt.json',
'!**/queries.gql',
'!**/queries*.gql',
'!**/*.yml',
'!**/*.md',
'!package.nls.json',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2589,7 +2589,7 @@
"test:preprocess": "yarn run compile:test && yarn run test:preprocess-gql && yarn run test:preprocess-svg",
"browsertest:preprocess": "tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs",
"browsertest": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js",
"test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql",
"test:preprocess-gql": "node scripts/preprocess-gql --in src/github/queries.gql --out out/src/github/queries.gql && node scripts/preprocess-gql --in src/github/queriesShared.gql --out out/src/github/queriesShared.gql && node scripts/preprocess-gql --in src/github/queriesLimited.gql --out out/src/github/queriesLimited.gql",
"test:preprocess-svg": "node scripts/preprocess-svg --in ../resources/ --out out/resources",
"update-dts": "cd \"src/@types\" && npx vscode-dts main && npx vscode-dts dev",
"watch": "webpack --watch --mode development --env esbuild",
Expand Down
11 changes: 11 additions & 0 deletions src/github/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,14 @@ export namespace OctokitCommon {
export type SearchReposResponseItem = Endpoints['GET /search/repositories']['response']['data']['items'][0];
export type CompareCommits = Endpoints['GET /repos/{owner}/{repo}/compare/{base}...{head}']['response']['data'];
}

export function mergeQuerySchemaWithShared(sharedSchema: { [key: string]: any, definitions: any[]; }, schema: { [key: string]: any, definitions: any[]; }) {
const sharedSchemaDefinitions = sharedSchema.definitions;
const schemaDefinitions = schema.definitions;
const mergedDefinitions = schemaDefinitions.concat(sharedSchemaDefinitions);
return {
...schema,
...sharedSchema,
definitions: mergedDefinitions
};
}
19 changes: 15 additions & 4 deletions src/github/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const PROMPT_FOR_SIGN_IN_STORAGE_KEY = 'login';

// If the scopes are changed, make sure to notify all interested parties to make sure this won't cause problems.
const SCOPES_OLD = ['read:user', 'user:email', 'repo'];
const SCOPES = ['read:user', 'user:email', 'repo', 'workflow'];
const SCOPES_WITH_ADDITIONAL = ['read:user', 'user:email', 'repo', 'workflow', 'read:org'];
const SCOPES = ['read:user', 'user:email', 'repo', 'workflow', 'project'];
const SCOPES_WITH_ADDITIONAL = ['read:user', 'user:email', 'repo', 'workflow', 'project', 'read:org'];

const LAST_USED_SCOPES_GITHUB_KEY = 'githubPullRequest.lastUsedScopes';
const LAST_USED_SCOPES_ENTERPRISE_KEY = 'githubPullRequest.lastUsedScopesEnterprise';
Expand Down Expand Up @@ -82,6 +82,10 @@ export class CredentialStore implements vscode.Disposable {
);
}

private allScopesIncluded(actualScopes: string[], requiredScopes: string[]) {
return requiredScopes.every(scope => actualScopes.includes(scope));
}

private setScopesFromState() {
this._scopes = this.context.globalState.get(LAST_USED_SCOPES_GITHUB_KEY, SCOPES);
this._scopesEnterprise = this.context.globalState.get(LAST_USED_SCOPES_ENTERPRISE_KEY, SCOPES);
Expand Down Expand Up @@ -217,9 +221,9 @@ export class CredentialStore implements vscode.Disposable {

public isAuthenticatedWithAdditionalScopes(authProviderId: AuthProvider): boolean {
if (!isEnterprise(authProviderId)) {
return !!this._githubAPI && this._scopes.length == SCOPES_WITH_ADDITIONAL.length;
return !!this._githubAPI && this.allScopesIncluded(this._scopes, SCOPES_WITH_ADDITIONAL);
}
return !!this._githubEnterpriseAPI && this._scopesEnterprise.length == SCOPES_WITH_ADDITIONAL.length;
return !!this._githubEnterpriseAPI && this.allScopesIncluded(this._scopesEnterprise, SCOPES_WITH_ADDITIONAL);
}

public getHub(authProviderId: AuthProvider): GitHub | undefined {
Expand All @@ -229,6 +233,13 @@ export class CredentialStore implements vscode.Disposable {
return this._githubEnterpriseAPI;
}

public areScopesOld(authProviderId: AuthProvider): boolean {
if (!isEnterprise(authProviderId)) {
return !this.allScopesIncluded(this._scopes, SCOPES);
}
return !this.allScopesIncluded(this._scopesEnterprise, SCOPES);
}

public async getHubEnsureAdditionalScopes(authProviderId: AuthProvider): Promise<GitHub | undefined> {
const hasScopesAlready = this.isAuthenticatedWithAdditionalScopes(authProviderId);
await this.initialize(authProviderId, { createIfNone: !hasScopesAlready }, SCOPES_WITH_ADDITIONAL);
Expand Down
76 changes: 56 additions & 20 deletions src/github/githubRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Protocol } from '../common/protocol';
import { GitHubRemote, parseRemote } from '../common/remote';
import { ITelemetry } from '../common/telemetry';
import { PRCommentControllerRegistry } from '../view/pullRequestCommentControllerRegistry';
import { OctokitCommon } from './common';
import { mergeQuerySchemaWithShared, OctokitCommon } from './common';
import { CredentialStore, GitHub } from './credentials';
import {
AssignableUsersResponse,
Expand All @@ -32,12 +32,14 @@ import {
PullRequestParticipantsResponse,
PullRequestResponse,
PullRequestsResponse,
RepoProjectsResponse,
ViewerPermissionResponse,
} from './graphql';
import {
CheckState,
IAccount,
IMilestone,
IProject,
Issue,
ITeam,
PullRequest,
Expand All @@ -49,6 +51,8 @@ import { IssueModel } from './issueModel';
import { LoggingOctokit } from './loggingOctokit';
import { PullRequestModel } from './pullRequestModel';
import defaultSchema from './queries.gql';
import * as limitedSchema from './queriesLimited.gql';
import * as sharedSchema from './queriesShared.gql';
import {
convertRESTPullRequestToRawPullRequest,
getAvatarWithEnterpriseFallback,
Expand Down Expand Up @@ -121,6 +125,7 @@ export class GitHubRepository implements vscode.Disposable {
public commentsController?: vscode.CommentController;
public commentsHandler?: PRCommentControllerRegistry;
private _pullRequestModels = new Map<number, PullRequestModel>();
private _queriesSchema: any;

private _onDidAddPullRequest: vscode.EventEmitter<PullRequestModel> = new vscode.EventEmitter();
public readonly onDidAddPullRequest: vscode.Event<PullRequestModel> = this._onDidAddPullRequest.event;
Expand Down Expand Up @@ -181,6 +186,7 @@ export class GitHubRepository implements vscode.Disposable {
private readonly _telemetry: ITelemetry,
silent: boolean = false
) {
this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, defaultSchema as any);
// kick off the comments controller early so that the Comments view is visible and doesn't pop up later in an way that's jarring
if (!silent) {
this.ensureCommentsController();
Expand Down Expand Up @@ -268,7 +274,7 @@ export class GitHubRepository implements vscode.Disposable {
};

get schema() {
return defaultSchema as any;
return this._queriesSchema;
}

async getMetadata(): Promise<IMetadata> {
Expand Down Expand Up @@ -307,38 +313,36 @@ export class GitHubRepository implements vscode.Disposable {
return true;
}

async ensure(): Promise<GitHubRepository> {
async ensure(additionalScopes: boolean = false): Promise<GitHubRepository> {
this._initialized = true;

const oldHub = this._hub;
if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) {
// We need auth now. (ex., a PR is already checked out)
// We can no longer wait until later for login to be done
await this._credentialStore.create();
await this._credentialStore.create(undefined, additionalScopes);
if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) {
this._hub = await this._credentialStore.showSignInNotification(this.remote.authProviderId);
}
} else {
this._hub = this._credentialStore.getHub(this.remote.authProviderId);
if (additionalScopes) {
this._hub = await this._credentialStore.getHubEnsureAdditionalScopes(this.remote.authProviderId);
} else {
this._hub = this._credentialStore.getHub(this.remote.authProviderId);
}
}

if (oldHub !== this._hub) {
if (this._credentialStore.areScopesOld(this.remote.authProviderId)) {
this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, limitedSchema.default as any);
} else {
this._queriesSchema = mergeQuerySchemaWithShared(sharedSchema.default as any, defaultSchema as any);
}
}
return this;
}

async ensureAdditionalScopes(): Promise<GitHubRepository> {
this._initialized = true;

if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) {
// We need auth now. (ex., a PR is already checked out)
// We can no longer wait until later for login to be done
await this._credentialStore.create(undefined, true);
if (!this._credentialStore.isAuthenticated(this.remote.authProviderId)) {
this._hub = await this._credentialStore.showSignInNotification(this.remote.authProviderId);
}
} else {
this._hub = await this._credentialStore.getHubEnsureAdditionalScopes(this.remote.authProviderId);
}

return this;
return this.ensure(true);
}

async getDefaultBranch(): Promise<string> {
Expand Down Expand Up @@ -489,6 +493,38 @@ export class GitHubRepository implements vscode.Disposable {
return undefined;
}

async getProjects(): Promise<IProject[] | undefined> {
try {
Logger.debug(`Fetch projects - enter`, GitHubRepository.ID);
let { query, remote, schema } = await this.ensure();
if (!schema.GetRepoProjects) {
const additional = await this.ensureAdditionalScopes();
query = additional.query;
remote = additional.remote;
schema = additional.schema;
}
const { data } = await query<RepoProjectsResponse>({
query: schema.GetRepoProjects,
variables: {
owner: remote.owner,
name: remote.repositoryName,
},
});
Logger.debug(`Fetch projects - done`, GitHubRepository.ID);

const projects: IProject[] = [];
if (data && data.repository.projectsV2 && data.repository.projectsV2.nodes) {
data.repository.projectsV2.nodes.forEach(raw => {
projects.push(raw);
});
}
return projects;
} catch (e) {
Logger.error(`Unable to fetch projects: ${e}`, GitHubRepository.ID);
return;
}
}

async getMilestones(includeClosed: boolean = false): Promise<IMilestone[] | undefined> {
try {
Logger.debug(`Fetch milestones - enter`, GitHubRepository.ID);
Expand Down
28 changes: 28 additions & 0 deletions src/github/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,14 @@ export interface UpdatePullRequestResponse {
};
}

export interface AddPullRequestToProjectResponse {
addProjectV2ItemById: {
item: {
id: string;
};
};
}

export interface GetBranchResponse {
repository: {
ref: {
Expand Down Expand Up @@ -547,6 +555,15 @@ export interface PullRequest {
viewerCanDisableAutoMerge: boolean;
isDraft?: boolean;
suggestedReviewers: SuggestedReviewerResponse[];
projectItems?: {
nodes: {
project: {
id: string;
title: string;
},
id: string
}[];
};
milestone?: {
title: string;
dueOn?: string;
Expand Down Expand Up @@ -590,6 +607,17 @@ export interface IssuesSearchResponse {
rateLimit: RateLimit;
}

export interface RepoProjectsResponse {
repository: {
projectsV2: {
nodes: {
title: string;
id: string;
}[];
}
}
}

export interface MilestoneIssuesResponse {
repository: {
milestones: {
Expand Down
11 changes: 11 additions & 0 deletions src/github/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ export function isSuggestedReviewer(
return 'isAuthor' in reviewer && 'isCommenter' in reviewer;
}

export interface IProject {
title: string;
id: string;
}

export interface IProjectItem {
id: string;
project: IProject;
}

export interface IMilestone {
title: string;
dueOn?: string | null;
Expand Down Expand Up @@ -130,6 +140,7 @@ export interface Issue {
updatedAt: string;
user: IAccount;
labels: ILabel[];
projectItems: IProjectItem[];
milestone?: IMilestone;
repositoryOwner?: string;
repositoryName?: string;
Expand Down
51 changes: 50 additions & 1 deletion src/github/issueModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { OctokitCommon } from './common';
import { GitHubRepository } from './githubRepository';
import {
AddIssueCommentResponse,
AddPullRequestToProjectResponse,
EditIssueCommentResponse,
TimelineEventsResponse,
UpdatePullRequestResponse,
} from './graphql';
import { GithubItemStateEnum, IAccount, IMilestone, IPullRequestEditData, Issue } from './interface';
import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, IPullRequestEditData, Issue } from './interface';
import { parseGraphQlIssueComment, parseGraphQLTimelineEvents } from './utils';

export class IssueModel<TItem extends Issue = Issue> {
Expand Down Expand Up @@ -276,6 +277,54 @@ export class IssueModel<TItem extends Issue = Issue> {
});
}

public async removeProjects(projectItems: IProjectItem[]): Promise<void> {
const { mutate, schema } = await this.githubRepository.ensure();

try {
await Promise.all(projectItems.map(project =>
mutate<void>({
mutation: schema.RemovePullRequestFromProject,
variables: {
input: {
itemId: project.id,
projectId: project.project.id
},
},
})));
this.item.projectItems = this.item.projectItems.filter(project => !projectItems.find(p => p.project.id === project.project.id));
} catch (err) {
Logger.error(err, IssueModel.ID);
}
}

private async addProjects(projects: IProject[]): Promise<void> {
const { mutate, schema } = await this.githubRepository.ensure();

try {
const itemIds = await Promise.all(projects.map(project =>
mutate<AddPullRequestToProjectResponse>({
mutation: schema.AddPullRequestToProject,
variables: {
input: {
contentId: this.item.graphNodeId,
projectId: project.id
},
},
})));
this.item.projectItems.push(...projects.map((project, index) => { return { project, id: itemIds[index].data!.addProjectV2ItemById.item.id }; }));
} catch (err) {
Logger.error(err, IssueModel.ID);
}
}

async updateProjects(projects: IProject[]): Promise<IProjectItem[]> {
const projectsToAdd: IProject[] = projects.filter(project => !this.item.projectItems.find(p => p.project.id === project.id));
const projectsToRemove: IProjectItem[] = this.item.projectItems?.filter(project => !projects.find(p => p.id === project.project.id)) ?? [];
await this.removeProjects(projectsToRemove);
await this.addProjects(projectsToAdd);
return this.item.projectItems;
}

async getIssueTimelineEvents(): Promise<TimelineEvent[]> {
Logger.debug(`Fetch timeline events of issue #${this.number} - enter`, IssueModel.ID);
const githubRepository = this.githubRepository;
Expand Down
Loading