Skip to content
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
7 changes: 3 additions & 4 deletions apps/agents/agent/channels/slack.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { connectSlackCredentials } from "@vercel/connect/eve";
import { slackChannel } from "eve/channels/slack";

import { slackCredentials } from "../lib/slack.js";

export default slackChannel({
credentials: connectSlackCredentials(
Comment thread
anthonyshew marked this conversation as resolved.
process.env.SLACK_CONNECT_UID ?? "slack/my-agent"
)
credentials: slackCredentials()
});
22 changes: 22 additions & 0 deletions apps/agents/agent/lib/slack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { connectSlackCredentials } from "@vercel/connect/eve";

function requiredEnvironmentVariable(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable ${name}.`);
}
return value;
}

export function slackCredentials() {
return connectSlackCredentials(
requiredEnvironmentVariable("SLACK_CONNECT_UID"),
{
installationId: requiredEnvironmentVariable("SLACK_INSTALLATION_ID")
}
);
}

export function slackDestinationChannel(): string {
return requiredEnvironmentVariable("SLACK_CHANNEL_ID");
}
128 changes: 115 additions & 13 deletions apps/agents/agent/tools/create_pull_request.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import type { SandboxSession } from "eve/sandbox";
import { callSlackApi } from "eve/channels/slack";
import { defineTool } from "eve/tools";
import { z } from "zod";

import { getGitHubToken } from "../lib/github.js";
import { isAppPrincipal, resolveAutomatedSelection } from "../lib/repo.js";
import { slackCredentials, slackDestinationChannel } from "../lib/slack.js";

const owner = "vercel";
const repo = "turborepo";
const baseBranch = "main";
const checkout = "turborepo";
const slackNotificationTimeoutMs = 5000;

const inputSchema = z.object({
branchName: z
Expand All @@ -30,6 +33,10 @@ type PullRequestResponse = {
html_url?: string;
number?: number;
};
type ValidatedPullRequest = {
number: number;
url: string;
};
type TreeEntry = {
path: string;
mode: "100644" | "100755";
Expand All @@ -55,6 +62,94 @@ function requireSha(value: string | undefined, label: string) {
return value;
}

function requirePullRequest(
response: PullRequestResponse
): ValidatedPullRequest {
if (
typeof response.number !== "number" ||
!Number.isSafeInteger(response.number) ||
response.number <= 0
) {
throw new Error(
"GitHub response did not include a valid pull request number."
);
}

const number = response.number;
const expectedUrl = `https://github.com/${owner}/${repo}/pull/${number}`;
if (response.html_url !== expectedUrl) {
throw new Error(
"GitHub response did not include the expected pull request URL."
);
}

return { number, url: expectedUrl };
}

async function notifyPullRequestCreated(pullRequest: ValidatedPullRequest) {
const attempt = Promise.resolve()
.then(() =>
callSlackApi({
botToken: slackCredentials().botToken,
operation: "chat.postMessage",
body: {
channel: slackDestinationChannel(),
text: `A new Turborepo pull request was created: #${pullRequest.number} ${pullRequest.url}`
}
})
)
.then((response) => response.ok)
.catch(() => false);
let timeout: NodeJS.Timeout | undefined;
const succeeded = await Promise.race([
attempt,
new Promise<false>((resolve) => {
timeout = setTimeout(resolve, slackNotificationTimeoutMs, false);
})
]);
if (timeout) clearTimeout(timeout);

if (!succeeded) {
logSlackNotificationFailure(pullRequest.number);
}
}

function logSlackNotificationFailure(pullRequestNumber: number) {
console.warn("Slack pull request notification failed.", {
event: "pull_request_notification_failed",
pullRequestNumber
});
}

async function findOpenPullRequests(branchName: string) {
return github<PullRequestResponse[]>({
method: "GET",
owner,
repo,
path: `/pulls?state=open&head=${owner}%3A${encodeURIComponent(branchName)}`
});
}

async function reconcileCreatedPullRequest(
response: PullRequestResponse,
branchName: string,
headSha: string
): Promise<ValidatedPullRequest> {
try {
return requirePullRequest(response);
} catch {
const pullRequest = (await findOpenPullRequests(branchName)).find(
(candidate) => candidate.head?.sha === headSha
);
if (!pullRequest) {
throw new Error(
"GitHub pull request response was invalid and could not be reconciled."
);
}
return requirePullRequest(pullRequest);
}
}

async function github<T>(input: {
body?: unknown;
method: "GET" | "PATCH" | "POST";
Expand Down Expand Up @@ -128,12 +223,7 @@ export default defineTool({
? `chore: Update ${selection.example} example`
: "chore: Update Turborepo examples";

const existingPullRequests = await github<PullRequestResponse[]>({
method: "GET",
owner,
repo,
path: `/pulls?state=open&head=${owner}%3A${encodeURIComponent(branchName)}`
});
const existingPullRequests = await findOpenPullRequests(branchName);
const existingPullRequest = existingPullRequests[0];

const checkoutSha = (await runGit(sandbox, "git rev-parse HEAD")).trim();
Expand Down Expand Up @@ -214,24 +304,29 @@ export default defineTool({
}

if (existingPullRequest) {
const validatedPullRequest = requirePullRequest(existingPullRequest);
const headSha = requireSha(
existingPullRequest.head?.sha,
"pull request head SHA"
);
const existingCommit = await github<CommitResponse>({
method: "GET",
owner,
repo,
path: `/git/commits/${requireSha(existingPullRequest.head?.sha, "pull request head SHA")}`
path: `/git/commits/${headSha}`
});
if (existingCommit.tree?.sha !== newTreeSha) {
throw new Error(
`Pull request ${existingPullRequest.html_url ?? existingPullRequest.number} already uses ${branchName} with different changes.`
`Pull request ${validatedPullRequest.url} already uses ${branchName} with different changes.`
);
}
return {
created: false,
existing: true,
number: existingPullRequest.number,
url: existingPullRequest.html_url,
number: validatedPullRequest.number,
url: validatedPullRequest.url,
branch: branchName,
commit: existingPullRequest.head?.sha
commit: headSha
};
}

Expand Down Expand Up @@ -298,11 +393,18 @@ export default defineTool({
draft: true
}
});
const validatedPullRequest = await reconcileCreatedPullRequest(
pullRequest,
branchName,
newCommitSha
);

await notifyPullRequestCreated(validatedPullRequest);

return {
created: true,
number: pullRequest.number,
url: pullRequest.html_url,
number: validatedPullRequest.number,
url: validatedPullRequest.url,
branch: branchName,
commit: newCommitSha
};
Expand Down
8 changes: 7 additions & 1 deletion apps/agents/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
"extends": ["//"],
"tasks": {
"build": {
"env": ["VERCEL", "VERCEL_DEPLOYMENT_ID"],
"env": [
"SLACK_CHANNEL_ID",
"SLACK_CONNECT_UID",
"SLACK_INSTALLATION_ID",
"VERCEL",
"VERCEL_DEPLOYMENT_ID"
],
"outputs": [
".eve/**",
".next/**",
Expand Down
Loading