Skip to content

Commit

Permalink
Merge pull request #8 from agoric-labs/feat/oracle-logic
Browse files Browse the repository at this point in the history
feat(oracle): github issue/pr verificaiton logic
  • Loading branch information
0xpatrickdev authored Nov 16, 2023
2 parents 4ce8749 + fc53f36 commit bb4303d
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 49 deletions.
110 changes: 110 additions & 0 deletions oracle-server/src/lib/octokit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,28 @@ import { readFileSync } from "fs";
import { Octokit } from "octokit";
import { createAppAuth } from "@octokit/auth-app";
import { getEnvVars } from "../utils/getEnvVar.js";
import { GitHubResourceParams } from "../utils/job.js";

let _octokit: InstanceType<typeof Octokit> | undefined;

let appAuth: ReturnType<typeof createAppAuth>;

function getAppAuth() {
if (appAuth) return appAuth;
const [APP_ID, PEM_PATH, INSTALLATION_ID] = getEnvVars([
"GITHUB_APP_ID",
"GITHUB_PEM_PATH",
"GITHUB_INSTALLATION_ID",
]);
appAuth = createAppAuth({
appId: APP_ID,
// @ts-expect-error string instead of buffer
privateKey: readFileSync(PEM_PATH),
installationId: INSTALLATION_ID,
});
return appAuth;
}

export function getOctokit() {
if (_octokit) return _octokit;
const [APP_ID, PEM_PATH, INSTALLATION_ID] = getEnvVars([
Expand All @@ -24,3 +43,94 @@ export function getOctokit() {

return _octokit;
}

export function getGraphql() {
return getOctokit().graphql.defaults({
request: {
hook: getAppAuth().hook,
},
});
}

// octokit instance scoped to the user's PAT
export function createUserOctokit(userAccessToken: string) {
return new Octokit({
auth: userAccessToken,
});
}


interface QueryData {
repository: {
pullRequest: {
merged: boolean;
state: "MERGED" | "OPEN" | "CLOSED";
author: { login: string; url: string };
closingIssuesReferences: {
nodes: {
number: number;
url: string;
assignees: {
nodes: {
resourcePath: string;
name: string;
}[];
};
state: "OPEN" | "CLOSED";
closed: boolean;
stateReason: "COMPLETED" | "REOPENED" | "NOT_PLANNED";
}[];
};
};
};
}

export async function queryPullRequest({
owner,
repo,
pull_number,
}: GitHubResourceParams<"pull">): Promise<QueryData> {
try {
const data = await getGraphql()(
`
query pullRequest($name: String!, $owner: String!, $num: Int!){
repository(name: $name, owner: $owner) {
pullRequest(number: $num) {
merged
state
author {
__typename
login
url
}
closingIssuesReferences (first:1) {
nodes {
number
url
assignees (first:1) {
nodes {
resourcePath
name
}
}
state
closed
stateReason
}
}
}
}
}
`,
{
owner,
name: repo,
num: pull_number,
}
);
return data as QueryData;
} catch (e) {
console.error("Error fetching PR", e);
throw new Error("Error fetching PR");
}
}
103 changes: 64 additions & 39 deletions oracle-server/src/routes/job.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import type { FastifyPluginCallback, FastifyRequest } from "fastify";
import isEqual from "lodash.isequal";
import { getOctokit } from "../lib/octokit.js";
import { createUserOctokit, queryPullRequest } from "../lib/octokit.js";
import { parseGitHubUrl } from "../utils/job.js";

type ProposeJob = { Body: { issueUrl: string; jobId: string } };
type ProposeJob = {
Body: { issueUrl: string; jobId: string };
Headers: {
authorization: string;
};
};
type ClaimJob = {
Body: {
prUrl: string;
walletAddress: string;
jobId: string;
issueUrl: string;
};
Headers: {
authorization: string;
};
};

Expand All @@ -19,63 +25,82 @@ export const job: FastifyPluginCallback = (fastify, _, done) => {
async function (request: FastifyRequest<ClaimJob>, reply) {
try {
// TODO get gh access_token from header
const { prUrl, issueUrl, jobId, walletAddress } = request.body;
const _userAccessToken = request.headers["authorization"]; // or however you pass the token
if (!_userAccessToken)
return reply.status(401).send("GitHub access token is required.");
const userAccessToken = _userAccessToken.split("Bearer ")[1];
if (!_userAccessToken)
return reply.status(401).send("Unable to parse access token.");

const userOctokit = createUserOctokit(userAccessToken);
if (!userOctokit) {
return reply
.status(401)
.send("Error validating GitHub access token.");
}
const { prUrl, jobId, walletAddress } = request.body;
const pullParams = parseGitHubUrl(prUrl, "pull");
const issueParams = parseGitHubUrl(issueUrl, "issue");
if (!pullParams)
return reply.status(400).send("Invalid pull request url.");
if (!issueParams) return reply.status(400).send("Invalid issue url.");
const pull = await getOctokit().rest.pulls.get(pullParams);
const issue = await getOctokit().rest.issues.get(issueParams);
/// XXX TODO
// check that jobId is associated with issueUrl
const query = await queryPullRequest(pullParams);
if (!query)
return reply.status(400).send("Unable to fetch PR details.");

// 1. check user's access_token == PR author (pull.data.user.id)
// XXX TODO
// 2. check that submitter was assigned the issue
if (!issue.data.assignee)
return reply.status(400).send("Issue does not have an assignee.");
if (issue.data.assignee.id !== pull.data.user.id)
const userData = await userOctokit.rest.users.getAuthenticated();
if (!userData)
return reply.status(401).send("Unable to fetch author profile data.");

const prRef = query.repository.pullRequest;

// 1. check user's access_token == PR author
if (userData.data.login !== prRef.author.login) {
return reply.status(403).send("User is not the pull request author.");
}

// 2. Check that PR has an issue
const issueRef =
query.repository.pullRequest.closingIssuesReferences.nodes[0];
if (!issueRef)
return reply
.status(400)
.send("Issue was not assigned to the pull request author.");
// 3. check that PR is closed & merged
if (pull.data.state !== "closed" || !pull.data.merged) {
.send("Issue reference not found for this pull request.");

// 3. check that submitter was assigned the issue
const assigneeRef = issueRef.assignees.nodes[0];
if (!assigneeRef)
return reply.status(400).send("Issue does not have an assignee.");
if (assigneeRef.resourcePath.replace("/", "") !== prRef.author.login) {
return reply
.status(400)
.send("Pull request has not been merged yet.");
.send("Issue is not assigned to the pull request author.");
}

// 4. check that PR is closed & merged
if (prRef.state !== "MERGED" || !prRef.merged) {
return reply.status(400).send("Pull request is not merged.");
}
// 4. check that PR closes provided issue
// XXX pull.data.issue_url == pull.url. this is not a reliable way to do this.
// instead, we need to check for github pull request comments and commit
// if (!pull.data.issue_url)
// return reply
// .status(400)
// .send("Associated issue was not found for this pull request.");

// const pullIssueParams = parseGitHubUrl(pull.data.issue_url, "issue");
// if (!isEqual(pullIssueParams, issueParams)) {
// return reply
// .status(400)
// .send(
// "Supplied issue does not match the issue associated with the pull request."
// );
// }
// 5. check the issue is closed and marked completed
if (
issue.data.state !== "closed" &&
issue.data.state_reason !== "completed"
issueRef.state !== "CLOSED" &&
issueRef.stateReason !== "COMPLETED"
) {
return reply
.status(400)
.send("Issue is still open or not marked completed.");
}

// 6. ensure the issue has a bounty
// 6.1 check that jobId is associated with issueUrl
/// XXX TODO, need to query blockchain state
const _issueUrl = issueRef.url;
const _issueNumber = issueRef.number;

// XXX TODO sendJobReport, all criteria met
// sendJobReport
const _w = walletAddress;

reply.send({ ok: true, data: pull.data, jobId });
reply.send({ ok: true, jobId });
} catch (e) {
console.error("/job/claim error", e);
reply.status(500).send("Unexpected error.");
Expand Down
7 changes: 4 additions & 3 deletions oracle-server/src/utils/job.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
type GitHubResourceType = "issue" | "pull";

type GitHubResourceParams<T extends GitHubResourceType> = T extends "issue"
? { owner: string; repo: string; issue_number: number }
: { owner: string; repo: string; pull_number: number };
export type GitHubResourceParams<T extends GitHubResourceType> =
T extends "issue"
? { owner: string; repo: string; issue_number: number }
: { owner: string; repo: string; pull_number: number };

export function parseGitHubUrl<T extends GitHubResourceType>(
url: string,
Expand Down
34 changes: 28 additions & 6 deletions web/src/components/ClaimBountyForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { useRef, FormEvent, ReactNode } from "react";
import { toast } from "react-toastify";
import { useMutation } from "@tanstack/react-query";
import { Button } from "./Button";
import { useWallet } from "../hooks/useWallet";
import {
ClaimBountyInput,
ClaimBountyOutput,
claimBounty,
} from "../lib/mutations";

interface ClaimBountyFormProps {
title: ReactNode;
Expand All @@ -13,13 +19,28 @@ const ClaimBountyForm = ({ title, description }: ClaimBountyFormProps) => {
const walletInputRef = useRef<HTMLInputElement>(null);
const { walletAddress } = useWallet();

const mutation = useMutation<ClaimBountyOutput, Error, ClaimBountyInput>({
mutationFn: claimBounty,
onSuccess: (data) => {
// Handle success
console.log("success", data);
toast.success("Bounty claimed successfully!");
},
onError: (error) => {
console.error(error);
toast.error(`Error: ${error.message}`);
},
});

const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!formRef.current) throw new Error("No form data");
const formData = new FormData(formRef.current);
const pullRequest = (formData.get("pullRequest") as string) || "";
const amount = (formData.get("amount") as string) || "";
console.log("onSubmit", { pullRequest, amount });
const prUrl = (formData.get("prUrl") as string) || "";
const jobId = (formData.get("jobId") as string) || "";
const walletAddress = (formData.get("walletAddress") as string) || "";
console.log("onSubmit", { prUrl, jobId, walletAddress });
mutation.mutate({ prUrl, jobId, walletAddress });
};

const handlePopulateAddress = () => {
Expand All @@ -45,16 +66,16 @@ const ClaimBountyForm = ({ title, description }: ClaimBountyFormProps) => {
<div className="mt-10 space-y-8 border-b border-gray-900/10 pb-12 sm:space-y-0 sm:divide-y sm:divide-gray-900/10 sm:border-t sm:pb-0">
<div className="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4 sm:py-6">
<label
htmlFor="pullRequest"
htmlFor="prUrl"
className="block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5"
>
Pull Request Url
</label>
<div className="mt-2 sm:col-span-3 sm:mt-0">
<input
type="text"
name="pullRequest"
id="pullRequest"
name="prUrl"
id="prUrl"
placeholder=""
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:max-w-sm sm:text-sm sm:leading-6"
/>
Expand Down Expand Up @@ -109,6 +130,7 @@ const ClaimBountyForm = ({ title, description }: ClaimBountyFormProps) => {
<div className="mt-2 sm:col-span-1 sm:mt-0 -ml-10 sm:-ml-20">
<button
className="text-xs bg-wild-sand-100 hover:bg-wild-sand-200 items-center justify-center rounded-md px-2 py-2 ml-4 my-auto mt-1"
type="button"
onClick={handlePopulateAddress}
>
Populate with Keplr
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/GitHubLoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { toast } from "react-toastify";
import { createId } from "@paralleldrive/cuid2";
import { Button } from "./Button";

const LOCAl_STORAGE_KEY = "githubAccessToken";
export const LOCAl_STORAGE_KEY = "githubAccessToken";
const loginSuccessToastId = createId();

const GitHubLoginButton = () => {
Expand Down
Loading

0 comments on commit bb4303d

Please sign in to comment.