Skip to content

Commit

Permalink
clone issues with netlify function... (CodeYourFuture#255)
Browse files Browse the repository at this point in the history
Adds clone issues functionality. Same as CodeYourFuture#182 but uses netlify functions for backend
  • Loading branch information
berkeli authored Sep 14, 2023
1 parent 37f1cc8 commit 16e6024
Show file tree
Hide file tree
Showing 20 changed files with 1,689 additions and 2 deletions.
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Allow accessing a GitHub bearer token to avoid rate limits when doing HTTP fetches to the GitHub API.
# This can be generated at https://github.com/settings/tokens?type=beta and needs read-only access to all public CYF GitHub repos.
CYF_CURRICULUM_GITHUB_BEARER_TOKEN=""
# Client ID and secret for the GitHub OAuth app used to authenticate users.
GITHUB_CLIENT_ID="clientid"
GITHUB_CLIENT_SECRET="clientsecret"
# The domain of the site, used for generating redirect URLs.
DOMAIN="http://localhost:8888"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ yarn-error.log*

# CI uses package-lock.json, don't allow yarn.lock
yarn.lock

# Local Netlify folder
.netlify
.env
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
27 changes: 27 additions & 0 deletions DEVELOPER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Github OAuth app

We use Github OAuth app to authenticate users for cloning issues. You can create your own Github OAuth app and use it for your local development.

Refer to: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app

The only important option from the OAuth app is the `Authorization callback URL`. You need to set it to `http://localhost:8888/api/clone`. This is the endpoint where netlify functions are running (./netlify/functions/clone).

## Environment Variables

You need to create a `.env` file in the root of the project. You can refer to `.env.example` file for the required variables.

## Running the project

As we use netlify functions, you will need netlify cli to run the project locally. You can install it using the following command:

```bash
npm install netlify-cli -g
```

Once you have netlify cli installed, you can run the project using the following command:

```bash
netlify dev
```

This will start the project on `http://localhost:8888`.
27 changes: 27 additions & 0 deletions assets/scripts/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ editableCodeBlocks.forEach((block) => {
block.setAttribute("autocapitalize", "off");
});

// alerts for issue cloning (error/success message)
const alert = document.querySelector(".c-alert");
const alertClose = alert.querySelector(".close");

// Fix for GFM task lists
// https://github.com/github/cmark-gfm/issues/299
window.addEventListener("DOMContentLoaded", (event) => {
Expand All @@ -56,4 +60,27 @@ window.addEventListener("DOMContentLoaded", (event) => {
const parent = item.parentNode;
parent.innerHTML = `<label>${parent.innerHTML}</label>`;
});

// get query param for clone issue message/error
const urlParams = new URLSearchParams(window.location.search);
const message = urlParams.get("message");
const error = urlParams.get("error");

if (message) {
alert.removeAttribute("hidden");
alert.classList.remove("c-alert--warning");
alert.classList.add("c-alert--info");
alert.querySelector(".alert__message").innerHTML = message;
}

if (error) {
alert.removeAttribute("hidden");
alert.classList.remove("c-alert--info");
alert.classList.add("c-alert--warning");
alert.querySelector(".alert__message").innerHTML = error;
}

alertClose.addEventListener("click", (event) => {
alert.setAttribute("hidden", true);
});
});
15 changes: 15 additions & 0 deletions assets/styles/04-components/alert.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.c-alert {
background-color: var(--theme-color--paper-fade);
z-index: 2;
position: relative;
padding: var(--theme-spacing--2) var(--theme-spacing--gutter) var(--theme-spacing--3);
width: 100%;

&__info {
background-color: var(--theme-color--ink-fade);
}

&__warning {
background-color: var(--theme-color--contrast-max);
}
}
13 changes: 13 additions & 0 deletions layouts/partials/issues.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{{ $repo := .Params.backlog }}
{{ $issues := print .Site.Params.orgapi $repo "/issues?per_page=100" }}
{{ $filter := .Params.backlog_filter }}
{{ $currentPath := .Page.RelPermalink }}
<!-- api call -->
{{ $headers := (dict) }}
{{ if ne (os.Getenv "CYF_CURRICULUM_GITHUB_BEARER_TOKEN") "" }}
Expand Down Expand Up @@ -30,9 +31,21 @@
{{ if strings.Contains .body "_No response_" }}
{{ errorf "Issue %s contains _No response_ - please edit the issue to remove it." .html_url }}
{{ end }}
<div class="c-alert" role="alert" hidden>
<span class="alert__message"></span>
<button aria-label="Close" class="clean-btn close" type="button">
<span aria-hidden="true">&times;</span>
</button>
</div>
<details class="c-issue">
<summary class="c-issue__title e-heading__3">
{{ .title }} <a class="c-issue__link" href="{{ .html_url }}">🔗</a>
<a
href="/api/clone?state={{ dict "issue" .number "module" $repo "sprint" $filter "prevPath" $currentPath | jsonify }}"
class="e-button"
data-props="{{ $currentPath }}">
Clone
</a>
</summary>
<div class="c-issue__body">{{ .body | markdownify }}</div>
</details>
Expand Down
4 changes: 3 additions & 1 deletion layouts/partials/page-header.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ <h1 class="c-page-header__title e-heading__1">
>{{ .Page.Title }}
</h1>
{{ if eq .Page.Params.layout "backlog" }}
<h2 class="c-page-header__subtitle c-page-header__backlog e-heading__3">
<h2
class="c-page-header__subtitle c-page-header__backlog e-heading__3"
style="display:flex; width:max-content;">
<a
class="c-page-header__link"
href="{{ .Page.Site.Params.org }}/{{ .Page.Params.backlog }}/issues"
Expand Down
13 changes: 13 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[build]
publish = "public"
command = "npm install --legacy-peer-deps && hugo --minify && npx pagefind --source=public"
[dev]
framework = "hugo"
targetPort = 3000
command = "hugo server -p 3000"
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
[functions]
external_node_modules = ["node-fetch"]
78 changes: 78 additions & 0 deletions netlify/functions/clone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions";
import { githubServiceBuilder } from "../helpers/github";
import {
checkState,
checkCode,
withHeaders,
makeCookieString,
getTokenFromCookies,
redirect,
clonedMessage,
} from "../helpers/util";
import config from "../helpers/config";
import { CloneResponse } from "../helpers/types";

const handler: Handler = async (event: HandlerEvent, context) => {
const response: HandlerResponse = {
statusCode: 200,
};

// check for state
// this endpoint is only to be used for cloning issues, so we should ignore everything that doesn't have state (see type for state).
const stateOrFail = checkState(event);
if ("statusCode" in stateOrFail) {
return withHeaders(stateOrFail);
}

// check for auth token in cookies
let token = getTokenFromCookies(event.headers);

if (!token) {
// check for code in query params
// we fail hard if we don't have a token AND code
const tokenOrFail = await checkCode(event, stateOrFail);
if (typeof tokenOrFail !== "string") {
return tokenOrFail;
}

token = tokenOrFail;

const cookieString = makeCookieString(token);
response.headers = {
"Set-Cookie": cookieString,
};
}

// if we have a token, we can now clone issues

const gh = await githubServiceBuilder(token);

const { module, sprint, issue, prevPath } = stateOrFail;

const url = new URL(prevPath || "/", config().domain);

// if issue is defined, we clone just that issue
if (issue) {
await gh.cloneIssue(module, issue).catch((err) => {
console.error(err);
url.searchParams.set("error", err.message);
return redirect(url.toString());
});

url.searchParams.set("message", "Issue cloned successfully");

return redirect(url.toString());
}

const resp = await gh.cloneAllIssues(module, sprint).catch((err) => {
console.error(err);
url.searchParams.set("error", err.message);
return redirect(url.toString());
});

url.searchParams.set("message", clonedMessage(resp as CloneResponse));

return redirect(url.toString());
};

export { handler };
43 changes: 43 additions & 0 deletions netlify/helpers/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Joi from "joi";
import * as dotenv from "dotenv";

dotenv.config();

const schema = Joi.object({
NODE_ENV: Joi.string().valid("dev", "prod", "test").default("dev"),
defaultRepo: Joi.string().default("My-Coursework-Planner"),
defaultOwner: Joi.string().default("CodeYourFuture"),
github: Joi.object({
oauth: Joi.object({
clientId: Joi.string().required(),
clientSecret: Joi.string().required(),
}),
}),
domain: Joi.string().default("localhost"),
});

const config = () => {
const { value, error } = schema.validate(
{
NODE_ENV: process.env.NODE_ENV,
defaultRepo: process.env.DEFAULT_REPO,
defaultOwner: process.env.DEFAULT_OWNER,
github: {
oauth: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
},
domain: process.env.DOMAIN,
},
{ abortEarly: false }
);

if (error) {
throw new Error(`Config validation error: ${error.message}`);
}

return value;
};

export default config;
20 changes: 20 additions & 0 deletions netlify/helpers/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import config from "./config";

export const GITHUB_DEFAULT_LABELS = [
"bug",
"documentation",
"duplicate",
"enhancement",
"good first issue",
"help wanted",
"invalid",
"question",
"wontfix",
];

export const COOKIE_NAME = "github-token";

export const DEFAULT_HEADERS = {
"Access-Control-Allow-Origin": config().domain,
"Access-Control-Allow-Credentials": true,
};
Loading

0 comments on commit 16e6024

Please sign in to comment.