Skip to content
This repository was archived by the owner on Jul 13, 2023. It is now read-only.

Commit 371bc2e

Browse files
authored
Merge pull request #21 from ColinKrist/feature/pr-comments
Feature/pr comments
2 parents 88af709 + cbc1f7b commit 371bc2e

26 files changed

+667
-121
lines changed

.vscode/settings.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"conventionalCommits.scopes": [
33
"example-projects",
4-
"Deploy Task"
4+
"Deploy Task",
5+
"PR Comments"
56
]
67
}

README.md

+12-55
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,22 @@ Yes, this extension is currently being [dogfooded](https://www.techopedia.com/de
1717
About Vercel: [link🔗](https://vercel.com/docs)
1818

1919
Ever stumble upon a repo on Github and see one of these bad boys?
20-
20+
> Picture:
2121
![alt](images/github-vercel-example.png)
2222

2323
They're badass - and now anyone using this Task (or roll your own - source code is MIT) something like this can be done pretty easily.
2424

2525
### What this extension / step does
2626

27-
This extension / "Task Deploy" step pushes code to Vercel and then exposes the final url as a shared pipeline variable. This can then be consumed by something like:
28-
29-
| Name | Link | Author(s) |
30-
| - | - | - |
31-
| Create Pull Request Comment | [link](https://github.com/microsoft/CSEDevOps/tree/main/CreatePrComment) | Microsoft's CSE Dev Team |
32-
33-
which can then create comments for you every time the PR updates. Now, this ain't perfect, may be a bit spammy. PR Metrics - another great extension OSS'ed from within MS for Azure Repos maintains a single comment.
34-
35-
For now, `deploymentUrl` is set for usage by the consumer of the step!
36-
37-
### What this extension / step does NOT do
27+
#### Deploy to Vercel
3828

39-
Provide a PR comment with the deployment link, and have it automatically update with the newest link.
29+
The primary functionality that this provides is to publish lambdas, or web builds to Vercel when running the PR CI or your standard pipeline.
30+
#### PR Comment
4031

41-
> 🧚‍♀️ TODO? Consider pitching in a PR to do this for us!
32+
To greater improve the PR experience, this extension also creates and updates a single comment on your PR with the latest PR build. It also exposes the current status of the Vercel deployment from the PR comment itself _\*chef kiss\*_.
4233

34+
> Picture:
35+
![alt](images/pr-comment.png)
4336

4437
---
4538

@@ -82,47 +75,11 @@ package rehydration / download
8275
workingDirectory: "examples/react-cra"
8376
- task: vercel-azdo-deploy@0
8477
inputs:
85-
# API token - not the projectId, this can be generated once and shared across projects
78+
# API token - (not the projectId) this can be generated once and shared across projects
8679
# ⚠ definitely recommend not plain texting this and using a pipeline variable ;)
8780
token: "69CHaMaOXm0wLlqGQoDBX3TB"
88-
# default is the pipeline part - here's an example possibly to be used within a monorepo
81+
# default is Build.SourceDirectory. Provided is an example for a nested repo, or for a monorepo (only supports 1 deployment from a monorepo at this time... TODO)
8982
path: "$(Build.SourcesDirectory)/examples/react-cra"
90-
```
91-
92-
---
93-
94-
## Recommended patterns / recipes
95-
96-
### Deploy and comment on PR creation / update
97-
98-
> The most familiar experience for anyone coming from Github, Gitlab, or Bitbucket
99-
100-
Covered in [What this extension does NOT do](#what-this-extension--step-does-not-do), comments on a PR are currently not handled by the extension / Task. Here's how to do a simple implementation that'll post a new comment whenever a PR is opened, and when new updates are pushed.
101-
102-
```yaml
103-
... nodeJS setup,
104-
package manager install,
105-
package caching? (I hope so it's 2022),
106-
package rehydration / download
107-
...
108-
109-
# Consumer of this step MUST build app prior to trying to upload it
110-
# Vercel can recognize these intracies typically, but in case it doesn't go here to learn about to configuring the project output directory within Vercel
111-
# https://vercel.com/docs/concepts/deployments/configure-a-build#build-and-development-settings
112-
- task: CmdLine@2
113-
displayName: Build
114-
inputs:
115-
script: "pnpm build"
116-
workingDirectory: "examples/react-cra"
117-
- task: vercel-azdo-deploy@0
118-
inputs:
119-
# API token - not the projectId, this can be generated once and shared across projects
120-
# ⚠ definitely recommend not plain texting this and using a pipeline variable ;)
121-
token: "69CHaMaOXm0wLlqGQoDBX3TB"
122-
# default is the pipeline part - here's an example possibly to be used within a monorepo
123-
path: "$(Build.SourcesDirectory)/examples/react-cra"
124-
- task: CreatePRCommentTask@1
125-
inputs:
126-
AuthType: "system"
127-
Comment: "Deployed to Vercel: $('deploymentUrl')"
128-
```
83+
env:
84+
SYSTEM_ACCESS_TOKEN: "$(System.AccessToken)"
85+
```

Task/Deploy/Template.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# 🔼 Vercel Deployment
2+
3+
| Name | Status | Link | Updated |
4+
| - | - | - | - |
5+
| {name} | {status} ([Inspect]({inspectorUrl})) | [Preview]({url}) | {new Date({ready})} |
6+
7+
_Functionality provided by [vercel-azdo](https://github.com/ColinKrist/vercel-azdo) leveraging [@vercel/client](https://www.npmjs.com/package/@vercel/client)_

Task/Deploy/azdoRepoClient/index.ts

+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { WebApi } from "azure-devops-node-api";
2+
import { IGitApi } from "azure-devops-node-api/GitApi";
3+
import { IRequestHandler } from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces";
4+
import {
5+
Comment,
6+
CommentThreadStatus,
7+
GitPullRequestCommentThread,
8+
} from "azure-devops-node-api/interfaces/GitInterfaces";
9+
import AzureDevOpsApiWrapper from "../azureDevopsApiClient";
10+
import { validateVariable } from "../envVariables/validator";
11+
import CommentData from "../models/PullRequests/CommentData";
12+
import FileCommentData from "../models/PullRequests/FileCommentData";
13+
import PullRequestCommentData from "../models/PullRequests/PullRequestCommentData";
14+
15+
export class client {
16+
public azureDevOpsApiWrapper: AzureDevOpsApiWrapper =
17+
new AzureDevOpsApiWrapper();
18+
19+
public project: string = "";
20+
public repositoryId: string = "";
21+
public pullRequestId: number = 0;
22+
public gitApi: IGitApi | undefined;
23+
24+
/**
25+
* Gets all the comments for the pull request.
26+
* @returns Promise<CommentData>
27+
*/
28+
public async getComments(): Promise<CommentData> {
29+
const gitApiPromise: IGitApi = await this.getGitApi();
30+
31+
const threads: GitPullRequestCommentThread[] =
32+
await gitApiPromise.getThreads(
33+
this.repositoryId,
34+
this.pullRequestId,
35+
this.project
36+
);
37+
38+
console.log(JSON.stringify(threads));
39+
40+
return client.convertPullRequestComments(threads);
41+
}
42+
43+
public async createComment(
44+
content: string,
45+
status: CommentThreadStatus
46+
): Promise<void> {
47+
console.debug("* AzdoRepoClient.createComment()");
48+
49+
const gitApi = await this.getGitApi();
50+
51+
const commentThread: GitPullRequestCommentThread = {
52+
comments: [{ content }],
53+
status,
54+
};
55+
56+
const result: GitPullRequestCommentThread = await gitApi.createThread(
57+
commentThread,
58+
this.repositoryId,
59+
this.pullRequestId,
60+
this.project
61+
);
62+
63+
console.debug(JSON.stringify(result));
64+
}
65+
66+
public async updateComment(
67+
commentThreadId: number,
68+
content: string | null,
69+
status: CommentThreadStatus | null
70+
): Promise<void> {
71+
console.debug("* AzdoRepoClient.updateComment()");
72+
73+
if (content === null && status === null) {
74+
return;
75+
}
76+
77+
const gitApi = await this.getGitApi();
78+
if (content !== null) {
79+
const comment: Comment = {
80+
content,
81+
};
82+
83+
const commentResult: Comment = await gitApi.updateComment(
84+
comment,
85+
this.repositoryId,
86+
this.pullRequestId,
87+
commentThreadId,
88+
1,
89+
this.project
90+
);
91+
92+
console.debug(JSON.stringify(commentResult));
93+
}
94+
95+
if (status !== null) {
96+
const commentThread: GitPullRequestCommentThread = {
97+
status,
98+
};
99+
100+
const threadResult: GitPullRequestCommentThread =
101+
await gitApi.updateThread(
102+
commentThread,
103+
this.repositoryId,
104+
this.pullRequestId,
105+
commentThreadId,
106+
this.project
107+
);
108+
109+
console.debug(JSON.stringify(threadResult));
110+
}
111+
}
112+
113+
/**
114+
* Translate API data to our model.
115+
* @param threads
116+
* @returns
117+
*/
118+
private static convertPullRequestComments(
119+
threads: GitPullRequestCommentThread[]
120+
): CommentData {
121+
const result: CommentData = new CommentData();
122+
123+
threads.forEach((thread: GitPullRequestCommentThread): void => {
124+
const id: number = thread.id ?? 0;
125+
const comments: Comment[] | undefined = thread.comments;
126+
if (comments === undefined) {
127+
return;
128+
}
129+
130+
const initialThreadComment: string | undefined = comments[0]?.content;
131+
if (initialThreadComment === undefined || initialThreadComment === "") {
132+
return;
133+
}
134+
135+
const threadStatus: CommentThreadStatus =
136+
thread.status ?? CommentThreadStatus.Unknown;
137+
138+
if (thread.threadContext === null || thread.threadContext === undefined) {
139+
result.pullRequestComments.push(
140+
new PullRequestCommentData(id, initialThreadComment, threadStatus)
141+
);
142+
} else {
143+
const fileName: string | undefined = thread.threadContext.filePath;
144+
if (fileName === undefined || fileName.length <= 1) {
145+
return;
146+
}
147+
148+
result.fileComments.push(
149+
new FileCommentData(
150+
id,
151+
initialThreadComment,
152+
fileName.substring(1),
153+
threadStatus
154+
)
155+
);
156+
}
157+
});
158+
159+
return result;
160+
}
161+
162+
/**
163+
* Configures the client to use the Azure DevOps API.
164+
* @returns Promise<IGitApi>
165+
*/
166+
private async getGitApi(): Promise<IGitApi> {
167+
console.debug("* client.getGitApi()");
168+
169+
if (this.gitApi !== undefined) {
170+
return this.gitApi;
171+
}
172+
173+
this.initPRVars();
174+
175+
const accessToken: string = validateVariable(
176+
"SYSTEM_ACCESS_TOKEN",
177+
"client.getGitApi()"
178+
);
179+
const authHandler: IRequestHandler =
180+
this.azureDevOpsApiWrapper.getPersonalAccessTokenHandler(accessToken);
181+
182+
const defaultUrl: string = validateVariable(
183+
"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI",
184+
"client.getGitApi()"
185+
);
186+
const connection: WebApi = this.azureDevOpsApiWrapper.getWebApiInstance(
187+
defaultUrl,
188+
authHandler
189+
);
190+
this.gitApi = await connection.getGitApi();
191+
192+
if (!this.gitApi) {
193+
throw new Error("Could not get GitApi");
194+
}
195+
196+
return this.gitApi;
197+
}
198+
199+
public initPRVars() {
200+
this.project = validateVariable("SYSTEM_TEAMPROJECT", "client.getGitApi()");
201+
202+
this.repositoryId = validateVariable(
203+
"BUILD_REPOSITORY_ID",
204+
"client.getGitApi()"
205+
);
206+
207+
this.pullRequestId = parseInt(
208+
validateVariable(
209+
"SYSTEM_PULLREQUEST_PULLREQUESTID",
210+
"pullRequestIdForAzurePipelines"
211+
),
212+
10
213+
);
214+
}
215+
}
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Licensed under the MIT License.
2+
3+
import { IRequestHandler } from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces";
4+
import * as azureDevOpsApi from "azure-devops-node-api";
5+
6+
export default class AzureDevOpsApiWrapper {
7+
/**
8+
* Gets a personal access token handler.
9+
* @param token The Azure DevOps API token.
10+
* @returns The personal access token handler.
11+
*/
12+
public getPersonalAccessTokenHandler(token: string): IRequestHandler {
13+
return azureDevOpsApi.getPersonalAccessTokenHandler(token);
14+
}
15+
16+
/**
17+
* Gets a web API instance on which the Azure DevOps operations can be invoked.
18+
* @param defaultUrl The default URL, which represents the base URL on which the operations are to be invoked.
19+
* @param authHandler The authentication handler instance.
20+
* @returns The web API instance.
21+
*/
22+
public getWebApiInstance(
23+
defaultUrl: string,
24+
authHandler: IRequestHandler
25+
): azureDevOpsApi.WebApi {
26+
return new azureDevOpsApi.WebApi(defaultUrl, authHandler);
27+
}
28+
}

Task/Deploy/envVariables/validator.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Validates that an environment variable is not invalid or `undefined` and throws an `TypeError` if this condition is not met.
3+
* @param variableName The name of the environment variable.
4+
* @param methodName The name of the calling method, for messaging purposes.
5+
* @returns The validated value.
6+
*/
7+
export function validateVariable(
8+
variableName: string,
9+
methodName: string
10+
): string {
11+
const value: string | undefined = process.env[variableName];
12+
return validateString(value, variableName, methodName);
13+
}
14+
15+
/**
16+
* Validates that a string value is not invalid, `null`, or `undefined` and throws an `TypeError` if this condition is not met.
17+
* @param value The value to validate.
18+
* @param valueName The name of the value, for messaging purposes.
19+
* @param methodName The name of the calling method, for messaging purposes.
20+
* @returns The validated value.
21+
*/
22+
export function validateString(
23+
value: string | null | undefined,
24+
valueName: string,
25+
methodName: string
26+
): string {
27+
if (value === null || value === undefined || value === "") {
28+
throw TypeError(
29+
`'${valueName}', accessed within '${methodName}', is invalid, null, or undefined '${String(
30+
value
31+
)}'.`
32+
);
33+
}
34+
35+
return value;
36+
}

0 commit comments

Comments
 (0)