Closed24

DenoでGitHubの草を取得する

kawarimidollkawarimidoll

https://github.com/settings/tokens/new からトークン作成
ページを辿っていく場合は以下


read:userの権限があればOK

Generate tokenするとトークンが表示される、例によってこのタイミングでしか見られないので保存しておく

そしたら.envに追記

.env.example
GITHUB_READ_USER_TOKEN=github_read_user_token
.env
GITHUB_READ_USER_TOKEN=xxxxxxxxx
kawarimidollkawarimidoll

とりあえず参考記事で載せられていたユーザー名を取得するだけのクエリを書いてみる

main.ts
import { ky } from "./deps.ts";
import { GITHUB_READ_USER_TOKEN } from "./env.ts";

const query = `
query {
  viewer {
    login
  }
}
`;

const url = "https://api.github.com/graphql";
const json = { query };

const result = await ky.post(url, {
  headers: { Authorization: `Bearer ${GITHUB_READ_USER_TOKEN}` },
  json,
}).json();

console.log(result);

実行してみる

❯ vr start
{ data: { viewer: { login: "kawarimidoll" } } }

良さげ

しかしkyは導入してから八面六臂の活躍だな…

kawarimidollkawarimidoll

本題

main.ts
  import { ky } from "./deps.ts";
  import { GITHUB_READ_USER_TOKEN } from "./env.ts";

+ const userName = "kawarimidoll";

  const query = `
- query {
-   viewer {
-     login
+ query($userName:String!) {
+   user(login: $userName){
+     contributionsCollection {
+       contributionCalendar {
+         totalContributions
+         weeks {
+           contributionDays {
+             contributionCount
+             date
+           }
+         }
+       }
+     }
    }
  }
  `;
+ const variables = `
+ {
+   "userName": "${userName}"
+ }
+ `;

  const url = "https://api.github.com/graphql";

- const json = { query };
+ const json = { query, variables };

  const result = await ky.post(url, {
    headers: { Authorization: `Bearer ${GITHUB_READ_USER_TOKEN}` },
    json,
  }).json();

  console.log(result);

うーんほとんど参考元のコピペになってしまった
実行するとこんな感じ

❯ vr start  
{ data: { user: { contributionsCollection: { contributionCalendar: [Object] } } } }
kawarimidollkawarimidoll
❯ vr start
{
  contributionsCollection: {
    contributionCalendar: {
      totalContributions: 2135,
      weeks: [
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object]
      ]
    }
  }
}

参考元にもあったけど、ページ上のcontributions数とはちょっとずれるみたい?
なんだろう、差が1しかないのでprivate repositoryの数とは違うと思うけど…

kawarimidollkawarimidoll

weeksは古いものから並んでいるみたい

main.ts
const json = { query, variables }; // ここまで先程のコードと同じ

const { data } = await ky.post(url, {
  headers: { Authorization: `Bearer ${GITHUB_READ_USER_TOKEN}` },
  json,
}).json();

const { weeks, totalContributions } = data?.user?.contributionsCollection
  ?.contributionCalendar;

if (!weeks || !totalContributions) {
  throw new Error("Could not get contributions data");
}

console.log(totalContributions + " contributions in the last year");
console.log(weeks[weeks.length - 1]);

最後の週を表示させると最新のデータが取れる

❯ vr start
2135 contributions in the last year
{
  contributionDays: [
    { contributionCount: 2, date: "2021-06-27" },
    { contributionCount: 6, date: "2021-06-28" }
  ]
}
kawarimidollkawarimidoll

オブジェクトの配列になっていると扱いづらいので単純な二次元配列に変換する

map()の引数に型が必要なのでinterfaceを定義する
extractedContributionsも後で使用するときに型を聞かれるのでつけておく

main.ts
console.log(totalContributions + " contributions in the last year"); // ここまで先程のコードと同じ

interface ContributionDay {
  contributionCount: number;
  date: string;
}

// 二次元配列に変換するついでに最大値も調べる
let maxContributionsCount = 0;
const extractedContributions: number[][] = weeks.map((
  week: { contributionDays: ContributionDay[] },
) =>
  week.contributionDays.map(
    (day) => {
      if (day.contributionCount > maxContributionsCount) {
        maxContributionsCount = day.contributionCount;
      }
      return day.contributionCount;
    },
  )
);
console.log(maxContributionsCount);
console.log(extractedContributions);
❯ vr start
2135 contributions in the last year
32
[
  [
    2, 0, 0, 0,
    0, 0, 0
  ],
  [
    1, 1, 0, 5,
    1, 5, 9
  ],
  [
(略)
kawarimidollkawarimidoll

やりたいことが見えてきたぞ

行列を転置して横に表示させる
https://qiita.com/shoheihagiwara/items/9cd7b54e5cd69a289ceb

最新週のrow[i]undefinedなので表示可否の判定を挟む
ただし、この場合${row[i] || ""}.padStart(3)とかrow[i] ? ${row[i]}.padStart(3) : ""だと0falsyなためうまくいかない(この行、mdのシンタックスとjsのシンタックスでバッククォートが重複していてうまく書けない)
ちゃんとrow[i] != nullでチェックする必要あり
…と思っていたけど??のおかげでシンプルに書けた

main.ts
// extractedContributionsを作るところまで同じ
extractedContributions[0].forEach((_, i) => {
  // 判定部は row[i] != null ? `${row[i]}`.padStart(3) : "" でもOK
  console.log(
    extractedContributions.map((row) => `${row[i] ?? ""}`.padStart(3)).join(""),
  );
});

実行すると以下のように横に並ぶ
50週 x 3字ずつ並んでいるので150字以上表示できる端末が必要

❯ vr start
2135 contributions in the last year
  2  1  5  1 13  5  2  4  5  9  1  2  3  3  3  8  3 10  1  4  1  1  6  2  4  2 10 10 10  9  8  6 13  4  7  7  6  6  3  1 11  1  5  2  6  6  6 21 11 17 13 16  2
  0  1  2  1  4  0  1  5  7  1  1  3  9  1  5  8  3  1  4  5  3  1  1  1  1  6  9 12  5  7  5  5 19  2 20 11  8 10 32  4  6  2  5  4  3  3  7 20  5 17  6  5  6
  0  0  2  4  0  5  0  7  8  3  3  3  2  2  5  1  1  4  1  3  1  1  2  1  1  7  8  9  4 10  2 14 12 18 11 22  6  7  7 14  1  3  2  5  1 18  5 20  7  4  6  5
  0  5  3  4  8  5  1  4  2  5  2  4  1  5  2  3  1  4  1  1  1  1  1  2  2  4 11  5  6  4  2  6 18 16  6  6  2  2  7  9  1  4  1  4  4  9  4 11  5 20  6 14
  0  1  0 13  6  1  0  0  5  1  4  3  1  6  3  2  1  1  2 10  1  1  1  2  2 11  4 18  4 15  4 12 24  4 23 15  4 17  8  5  4  2  2 14  2 12  6  9  1  9  4  2
  0  5  9 10  2  3  3  2  3  1  3  1  9  5  6  1  1  1  3  1  1  1  1  2  1  9  7 23  6 13  6  1 14  6  3 12  3  4  6  2  1  2  6 14  6 12  6 15  2 10  4 18
  0  9  0  5  7  3  3 10  4  5  4  3  2  4  1  6  4  1  4  2  2  3  1  2  1 10  7 28  6 12  8 17  7  6  4 14 13  3  5 15  2  5  4 11  6 15 24 20  8 19  9  9
kawarimidollkawarimidoll

難儀しました

import { rgb24 } from "https://deno.land/std@0.99.0/fmt/colors.ts"; // import追加

// 途中は略
// extractedContributionsを作るところまで同じ

// const colors = [
//   "#ebedf0",
//   "#9be9a8",
//   "#40c463",
//   "#30a14e",
//   "#216e39",
// ];

const fillPixel = (count: number) => {
  if (count === 0) {
    return { r: 253, g: 237, b: 240 };
  } else if (count < maxContributionsCount / 4) {
    return { r: 155, g: 233, b: 168 };
  } else if (count < maxContributionsCount / 2) {
    return { r: 64, g: 196, b: 99 };
  } else if (count < maxContributionsCount * 3 / 4) {
    return { r: 48, g: 161, b: 78 };
  }
  return { r: 33, g: 110, b: 57 };
};

const grass = (count?: number) =>
  (count == null) ? "" : rgb24("■", fillPixel(count));

extractedContributions[0].forEach((_, i) => {
  console.log(
    extractedContributions.map((row) => grass(row[i])).join(""),
  );
});

キモは true colorに対応した端末を使うこと ですね

kawarimidollkawarimidoll

macデフォルトのterminal.appを使っていたので悲しいことになっていた

なんというかterminal.appで十分だと思っていたけど乗り換えどきだなこれは…

kawarimidollkawarimidoll

あーそうか、GraphQLだから問い合わせの段階で項目を選べるじゃない

 const query = `
  query($userName:String!) {
    user(login: $userName){
      contributionsCollection {
        contributionCalendar {
          totalContributions
          weeks {
            contributionDays {
              color
+             contributionCount
              date
            }
          }
        }
      }
    }
  }
  `;

(略)
console.log(weeks[weeks.length - 1]);
❯ vr start
Check file:///Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/main.ts
2135 contributions in the last year
{
  contributionDays: [
    {
      color: "#9be9a8",
      contributionCount: 2,
      date: "2021-06-27"
    },
    {
      color: "#9be9a8",
      contributionCount: 6,
      date: "2021-06-28"
    },
    {
      color: "#ebedf0",
      contributionCount: 0,
      date: "2021-06-29"
    }
  ]
}

つまりここから色情報を取り出せばプログラム内で保持する必要はない

kawarimidollkawarimidoll

これでOKだった

// https://lab.syncer.jp/Web/JavaScript/Snippet/61/
const hexToRgb = (hex: string) => {
  if (hex.slice(0, 1) == "#") hex = hex.slice(1);
  if (hex.length == 3) {
    hex = hex.slice(0, 1) + hex.slice(0, 1) + hex.slice(1, 2) +
      hex.slice(1, 2) + hex.slice(2, 3) + hex.slice(2, 3);
  }

  const r = parseInt(hex.slice(0, 2), 16);
  const g = parseInt(hex.slice(2, 4), 16);
  const b = parseInt(hex.slice(4, 6), 16);

  return { r, g, b };
};

const grass = (color?: string) => color ? rgb24("■", hexToRgb(color)) : "";

extractedContributions[0].forEach((_, i) => {
  console.log(
    extractedContributions.map((row) => grass(row[i]?.color)).join(""),
  );
});

自分で定義してないから良い感じに色を変えるみたいなことは難しいかも

kawarimidollkawarimidoll

https://github.com/williambelle/github-contribution-color-graph/blob/master/src/js/contentscript.js

カラースキーム追加できる

main.ts
const github = ["#9be9a8", "#40c463", "#30a14e", "#216e39"];
const halloween = ["#fdf156", "#ffc722", "#ff9711", "#04001b"];
const amber = ["#ffecb3", "#ffd54f", "#ffb300", "#ff6f00"];
//(もっと追加できる)

const colorSchemes = {
  github,
  halloween,
  amber,
};
const colorSchemeName = "halloween";
const baseColor = "#eeeeee";

const fillPixel = (day?: ContributionDay) => {
  const colors = colorSchemes[colorSchemeName];
  switch (day?.contributionLevel) {
    case "FIRST_QUARTILE":
      return hexToRgb(colors[0]);
    case "SECOND_QUARTILE":
      return hexToRgb(colors[1]);
    case "THIRD_QUARTILE":
      return hexToRgb(colors[2]);
    case "FOURTH_QUARTILE":
      return hexToRgb(colors[3]);
  }
  // case "NONE" or undefined
  return hexToRgb(baseColor);
};
kawarimidollkawarimidoll

https://deno.com/deploy

ローカル実行から試そうかね

hello_deploy.ts
addEventListener("fetch", (event) => {
  const response = new Response("Hello World!", {
    headers: { "content-type": "text/plain" },
  });
  event.respondWith(response);
});
❯ deno install --allow-read --allow-write --allow-env --allow-net --allow-run --no-check -f https://deno.land/x/deploy/deployctl.ts
❯ deployctl run --watch ./main.ts
kawarimidollkawarimidoll

とりあえずcontributionsの方をモジュール化しよう

contributions.ts
import { ky, rgb24 } from "./deps.ts";
import { ContributionDay } from "./types.ts";
import { getColorScheme } from "./color_scheme.ts";

const contributions = async (userName: string, token: string) => {
  if (!userName || !token) {
    throw new Error("Missing required arguments");
  }

  const query = `(略) `;
  const variables = `(略) `;

  const json = { query, variables };
  const url = "https://api.github.com/graphql";
  const { data } = await ky.post(url, {
    headers: { Authorization: `Bearer ${token}` },
    json,
  }).json();

  const { weeks, totalContributions }: {
    weeks: { contributionDays: ContributionDay[] }[];
    totalContributions: number;
  } = data?.user?.contributionsCollection
    ?.contributionCalendar;

  if (!weeks || !totalContributions) {
    throw new Error("Could not get contributions data");
  }

  const total = totalContributions + " contributions in the last year\n";

  const colorScheme = getColorScheme();
  const grass = (day?: ContributionDay) =>
    day?.color ? rgb24("■", colorScheme(day?.contributionLevel)) : "";

  return total +
    weeks[0].contributionDays.map((_, i) =>
      weeks.map((row) => grass(row.contributionDays[i])).join("")
    ).join("\n");

};

export { contributions };
kawarimidollkawarimidoll

けっこうリファクタリングできてきた(一部抜粋)

contributions.ts
  const colorScheme = getColorScheme(options.scheme);

  const total = (options.total ?? true)
    ? totalContributions + " contributions in the last year\n"
    : "";
  const legend = (options.legend ?? true)
    ? "\n Less " + colorScheme.colors.map((color) =>
      rgb24("■", color)
    ).join("") + " More"
    : "";

  const grass = (day?: ContributionDay) =>
    day?.contributionLevel
      ? rgb24("■", colorScheme.getByLevel(day?.contributionLevel))
      : "";

  return total +
    weeks[0].contributionDays.map((_, i) =>
      weeks.map((row) => grass(row.contributionDays[i])).join("")
    ).join("\n") + legend;
color_scheme.ts
const randomColorScheme = () => {
  const values = Object.values(colorSchemes);
  return values[Math.floor(Math.random() * values.length)];
};

const getColorScheme = (name?: ColorSchemeName | "random") => {
  const colors = [
    baseColor,
    ...(name === "random"
      ? randomColorScheme()
      : colorSchemes[name ?? "github"]),
  ].map((color) =>
    parseInt(
      "0x" + color.replace(
        /^#?(.*)$/,
        (_, hex) => (hex.length == 3) ? hex.replace(/./g, "$&$&") : hex,
      ),
    )
  );

  const getByLevel = (levelName?: ContributionLevelName) => {
    switch (levelName) {
      case "FIRST_QUARTILE":
        return colors[1];
      case "SECOND_QUARTILE":
        return colors[2];
      case "THIRD_QUARTILE":
        return colors[3];
      case "FOURTH_QUARTILE":
        return colors[4];
    }
    // case "NONE" or undefined
    return colors[0];
  };

  return { colors, getByLevel };
};

export { getColorScheme };

特にgetColorSchemeの実装は自分でもかなり気持ち良いワンライナー

https://stackoverflow.com/questions/40358037/doubling-each-letter-in-a-string-in-js

kawarimidollkawarimidoll

その後も色々修正したけど省略
deployに着手

❯  deno install --allow-read --allow-write --allow-env --allow-net --allow-run --no-check -f https://deno.land/x/deploy/deployctl.ts

Download https://deno.land/x/deploy/deployctl.ts
Warning Implicitly using latest version (0.3.0) for https://deno.land/x/deploy/deployctl.ts
(各種Downloadは省略)
✅ Successfully installed deployctl
/Users/kawarimidoll/.deno/bin/deployctl

server.ts作成 なんかrespondWithが無いっていうエラーが出たけど気にしない

server.ts
addEventListener("fetch", (event) => {
  const response = new Response("Hello World!", {
    headers: { "content-type": "text/plain" },
  });
  event.respondWith(response);
});

実行

❯  deployctl run --watch ./server.ts
Download https://deno.land/x/deploy@0.3.0/src/runtime.bundle.js
(Check省略)
Listening on http://0.0.0.0:8080

表示される

❯ curl http://0.0.0.0:8080
Hello World

--watchしているのでserver.tsを修正するとrestartする(ブラウザのhot reloadまではされない)

server.ts
  addEventListener("fetch", (event) => {
-   const response = new Response("Hello World!", {
+   const response = new Response("Hello Deno Deploy!", {
      headers: { "content-type": "text/plain" },
    });
    event.respondWith(response);
  });
❯ curl http://0.0.0.0:8080
Hello Deno Deploy!
kawarimidollkawarimidoll

velociraptor.ymlにサーバー起動スクリプトを追加

velociraptor.yml
(その他は略)
+ scripts:
+   dev:
+     desc: Starts local server
+     cmd: deployctl run --watch ./server.ts

めっちゃ基本的なハンドリングを設定
0.0.0.0:8080にアクセスするとHTMLが、0.0.0.0:8080/usernameにアクセスするとテキストメッセージが表示される
type=jsonパラメータを付けるとJSONが返される
ローレベルだが今回はこれができればOK

server.ts
function handleRequest(request: Request) {
  const { pathname, searchParams } = new URL(request.url);
  console.log({ pathname, searchParams });

  if (pathname === "/") {
    const html = `<html>
    <p>Welcome to deno-github-contributions-api!</p>
    <p>Access to /[username] to get your contributions graph</p>
    </html>`;
    return new Response(html, {
      headers: { "content-type": "text/html; charset=utf-8" },
    });
  }

  const message = `username: ${pathname.slice(1)}`;

  if (searchParams.get("type") === "json") {
    return new Response(JSON.stringify({ message }), {
      headers: { "content-type": "application/json; charset=utf-8" },
    });
  }

  return new Response(message, {
    headers: { "content-type": "text/plain; charset=utf-8" },
  });
}

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event.request));
});
kawarimidollkawarimidoll

意気揚々とcontributions()を呼び出そうとしたらエラー

❯ vr dev
Check file:///Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-github-contributions-api/$deno$eval.ts
error: TS2339 [ERROR]: Property 'readFileSync' does not exist on type 'typeof Deno'.
    return parse(new TextDecoder("utf-8").decode(Deno.readFileSync(filepath)));
                                                      ~~~~~~~~~~~~
    at https://deno.land/x/dotenv@v2.0.0/mod.ts:76:55

TS2339 [ERROR]: Property 'errors' does not exist on type 'typeof Deno'.
    if (e instanceof Deno.errors.NotFound) return {};
                          ~~~~~~
    at https://deno.land/x/dotenv@v2.0.0/mod.ts:78:27

Found 2 errors.

dotenvでエラー…?なぜ

kawarimidollkawarimidoll

issue上がってた
https://github.com/denoland/deployctl/issues/35
https://github.com/denoland/deployctl/issues/19

https://twitter.com/KawarimiDoll/status/1410563387742846979

deployctlにはfsが無いので--env=.envオプションを指定して環境変数にアクセスする

velociraptor.yml
  allow:
    - read=.env
    - env
    - net
+ envFile:
+   - .env

  scripts:
    dev:
      desc: Starts local server
-     cmd: deployctl run --watch ./server.ts
+     cmd: deployctl run --watch --env=.env ./server.ts

通常のスクリプトと共存するためにはdeno-dotenvは使えないので自分で実装する

env.ts
const GITHUB_READ_USER_TOKEN = Deno.env.get("GITHUB_READ_USER_TOKEN") ?? "";

if (!GITHUB_READ_USER_TOKEN) {
  throw new Error("No token!");
}

export { GITHUB_READ_USER_TOKEN };
kawarimidollkawarimidoll

トークン名を4回も書かないといけないのでDRYにしたいと思い、変えてみたけど…

env.ts
const env: { [key: string]: string } = {};
const envNames = [
  "GITHUB_READ_USER_TOKEN",
];

envNames.forEach((envName) => {
  const envValue = Deno.env.get(envName);

  if (!envValue) {
    throw new Error(`No token: ${envName}`);
  }

  env[envName] = envValue;
});

export default env;

exportされているメンバーをTSコンパイラが認識できないので読み込みのときにいったん変数を介さないといけないのが直感的じゃないかな まあこういう運用にしていけば良いか

- import { GITHUB_READ_USER_TOKEN } from "./env.ts";
+ import env from "./env.ts";
+ const { GITHUB_READ_USER_TOKEN } = env;

もしくは使用箇所で逐一Deno.env.get()を使ってエラーハンドリングもする

このスクラップは2021/07/19にクローズされました