Compare commits

...

6 commits

8 changed files with 180 additions and 139 deletions

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# OKImeter
Compte vos OKIs.
## Usage
1. Télécharger ce programme.
```sh
wget https://git.barnulf.net/mb/okimeter/releases/download/latest/okimeter
chmod a+x okimeter
```
2. Aller chercher un token sur le gitlab concerné.
- disponible sur [cette page gitlab](https://gitlab.cri.epita.fr/-/user_settings/personal_access_tokens)
- pendant la création, cocher au moins `read_api`
3. Exécuter ce programme avec le token récupéré.
```sh
./okimeter <TOKEN>
# fetching api/v4/merge_requests?per_page=100&page=1
# fetching api/v4/merge_requests?per_page=100&page=2
# fetching api/v4/projects/1/merge_requests/2/notes?per_page=100&page=1
# fetching api/v4/projects/2/merge_requests/1/notes?per_page=100&page=1
# fetching api/v4/projects/3/merge_requests/1/notes?per_page=100&page=1
# fetching api/v4/projects/4/merge_requests/1/notes?per_page=100&page=1
# fetching api/v4/projects/1/merge_requests/1/notes?per_page=100&page=2
# fetching api/v4/projects/2/merge_requests/1/notes?per_page=100&page=2
# fetching api/v4/projects/3/merge_requests/2/notes?per_page=100&page=2
# fetching api/v4/projects/4/merge_requests/1/notes?per_page=100&page=2
Total 'oki' 14
```

View file

@ -2,7 +2,7 @@
set -e
cd "$(dirname "$(realpath "$0")")"
MAIN="main.ts"
MAIN="src/okimeter.ts"
ARGS="--allow-net"
BIN="okimeter"

138
main.ts
View file

@ -1,138 +0,0 @@
#!/bin/env -S deno run -A
import { encodeBase64 } from "https://deno.land/std@0.222.1/encoding/base64.ts";
import { assert } from "https://deno.land/std@0.222.1/assert/assert.ts";
import { assertEquals } from "https://deno.land/std@0.222.1/assert/assert_equals.ts";
import { crayon } from "https://deno.land/x/crayon@3.3.3/mod.ts";
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
async function main() {
let { token } = parse_args(Deno.args);
if (token === undefined) token = prompt_args();
const client = new ApiClient("gitlab.cri.epita.fr", token);
const merge_requests = await get_all_merge_requests(client);
const total = sum(await parallel(merge_requests, (mr) => count_in_mr(mr, client)));
console.log("Total 'oki'", total);
}
function parse_args(args: string[]) {
const [token_part] = args;
const token = token_part as string | undefined;
return { token };
}
function prompt_args() {
const [bl, gr] = [crayon.blue, crayon.lightBlack];
const message = `
${bl("Please enter a read-allowed gitlab token :")}
${gr("You can get one from https://gitlab.cri.epita.fr/-/user_settings/personal_access_tokens")}
${gr("with at least the permission 'read_api', format is :")}
${gr("> xxxxxxxxxxxxxxxxxxxx")}
>`.trim();
const result = prompt(message);
assert(result !== null);
return result.trim();
}
class ApiClient {
url;
token;
constructor(hostname: string, token: string) {
this.url = `https://${hostname}`;
this.token = token;
}
async fetch(path: string) {
console.log(crayon.lightBlack("\tfetching", crayon.bold(path)));
const headers = new Headers([["PRIVATE-TOKEN", this.token]]);
const result = await fetch(this.url + "/" + path, { headers });
const parsed = await result.json();
return parsed as unknown;
}
async fetch_parse<T>(path: string, parser: z.ZodType<T>) {
const unkn = await this.fetch(path);
return parser.parse(unkn);
}
async fetch_parse_paginated<T>(path: string, parser: z.ZodType<T>) {
const result = [] as T[];
let page = 1;
while (true) {
const fetched = await this.fetch_parse(path + "?per_page=100&page=" + page, parser.array());
if (fetched.length === 0) break;
result.push(...fetched);
page += 1;
}
return result;
}
}
type MergeRequest = z.infer<typeof merge_request_parser>;
const merge_request_parser = z.object({
iid: z.number(),
project_id: z.number(),
reviewers: z.array(z.object({
username: z.string(),
})),
});
async function get_all_merge_requests(client: ApiClient): Promise<MergeRequest[]> {
return await client.fetch_parse_paginated(`api/v4/merge_requests`, merge_request_parser);
}
function sum(numbers: Iterable<number>) {
let result = 0;
for (const item of numbers) result += item;
return result;
}
Deno.test("test_sum", () => {
assertEquals(sum([1, 2, 3]), 6);
});
async function parallel<I, O>(inputs: I[], operation: (item: I) => Promise<O>) {
const promises = [] as Promise<O>[];
for (const input of inputs) promises.push(operation(input));
return await Promise.all(promises);
}
async function count_in_mr(mr: MergeRequest, client: ApiClient) {
let total = 0;
const mr_path = `api/v4/projects/${mr.project_id}/merge_requests/${mr.iid}`;
const all_notes = await client.fetch_parse_paginated(`${mr_path}/notes`, note_parser);
for (const c of all_notes) {
if (!await is_relevant(c.author.username)) continue;
if (c.body.toLowerCase().includes("oki")) total += 1;
}
return total;
}
const note_parser = z.object({
body: z.string(),
author: z.object({
username: z.string(),
}),
});
async function hash(namespace: string) {
const buffer = new TextEncoder().encode(namespace);
const hash = await crypto.subtle.digest("SHA-512", buffer);
const base64 = encodeBase64(hash);
return base64;
}
async function is_relevant(text: string) {
return (await hash(text)) === relevant_hash;
}
const relevant_hash = "Qa4xy8yRPnBsQf8U2UUE7tse2u4Fi/Aqxbt" +
"6aq56m5ofcmCkTi8PbgMBF1OtHC6qkXDF2tz2qKprYCIAMzTSQQ==";
Deno.test("encode_namespace", async () => {
const namespace = "CHANGEME";
const base64 = await hash(namespace);
console.log({ namespace, base64 });
});
if (import.meta.main) await main();

34
src/lib/client.ts Normal file
View file

@ -0,0 +1,34 @@
import { crayon } from "https://deno.land/x/crayon@3.3.3/mod.ts";
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
export class ApiClient {
url;
token;
constructor(hostname: string, token: string) {
this.url = `https://${hostname}`;
this.token = token;
}
async fetch(path: string) {
console.log(crayon.lightBlack("\tfetching", crayon.bold(path)));
const headers = new Headers([["PRIVATE-TOKEN", this.token]]);
const result = await fetch(this.url + "/" + path, { headers });
const parsed = await result.json();
return parsed as unknown;
}
async fetch_parse<T>(path: string, parser: z.ZodType<T>) {
const unkn = await this.fetch(path);
return parser.parse(unkn);
}
async *fetch_parse_paginated<T>(path: string, parser: z.ZodType<T>): AsyncIterable<T> {
let page = 1;
while (true) {
const fetched = await this.fetch_parse(path + "?per_page=100&page=" + page, parser.array());
yield* fetched;
if (fetch.length < 100) break;
page += 1;
}
}
}

21
src/lib/crypto.ts Normal file
View file

@ -0,0 +1,21 @@
import { encodeBase64 } from "https://deno.land/std@0.222.1/encoding/base64.ts";
export async function is_relevant(text: string) {
return (await hash(text)) === relevant_hash;
}
async function hash(namespace: string) {
const buffer = new TextEncoder().encode(namespace);
const hash = await crypto.subtle.digest("SHA-512", buffer);
const base64 = encodeBase64(hash);
return base64;
}
const relevant_hash = "Qa4xy8yRPnBsQf8U2UUE7tse2u4Fi/Aqxbt" +
"6aq56m5ofcmCkTi8PbgMBF1OtHC6qkXDF2tz2qKprYCIAMzTSQQ==";
Deno.test("encode_namespace", async () => {
const namespace = "CHANGEME";
const base64 = await hash(namespace);
console.log({ namespace, base64 });
});

17
src/lib/parser.ts Normal file
View file

@ -0,0 +1,17 @@
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
export type MergeRequest = z.infer<typeof merge_request_parser>;
export const merge_request_parser = z.object({
iid: z.number(),
project_id: z.number(),
reviewers: z.array(z.object({
username: z.string(),
})),
});
export const note_parser = z.object({
body: z.string(),
author: z.object({
username: z.string(),
}),
});

16
src/lib/utils.ts Normal file
View file

@ -0,0 +1,16 @@
export function sum(numbers: Iterable<number>) {
let result = 0;
for (const item of numbers) result += item;
return result;
}
export async function parallel<I, O>(inputs: AsyncIterable<I>, operation: (item: I) => Promise<O>) {
const promises = [] as Promise<O>[];
for await (const input of inputs) promises.push(operation(input));
return await Promise.all(promises);
}
export async function collect<T>(gen: AsyncIterable<T>) {
const result = [] as T[];
for await (const item of gen) result.push(item);
return result;
}

57
src/okimeter.ts Executable file
View file

@ -0,0 +1,57 @@
#!/bin/env -S deno run -A
import { assert } from "https://deno.land/std@0.222.1/assert/assert.ts";
import { crayon } from "https://deno.land/x/crayon@3.3.3/mod.ts";
import { is_relevant } from "./lib/crypto.ts";
import { ApiClient } from "./lib/client.ts";
import { parallel, sum } from "./lib/utils.ts";
import { merge_request_parser, MergeRequest, note_parser } from "./lib/parser.ts";
async function main() {
let { token } = parse_args(Deno.args);
if (token === undefined) token = prompt_args();
const client = new ApiClient("gitlab.cri.epita.fr", token);
const merge_requests = get_all_merge_requests(client);
const total = sum(await parallel(merge_requests, (mr) => count_in_merge_request(mr, client)));
console.log("Total", total, "oki");
}
function parse_args(args: string[]) {
const [token_part] = args;
const token = token_part as string | undefined;
return { token };
}
function prompt_args() {
const [bl, gr] = [crayon.blue, crayon.lightBlack];
const message = `
${bl("Please enter a read-allowed gitlab token :")}
${gr("You can get one from https://gitlab.cri.epita.fr/-/user_settings/personal_access_tokens")}
${gr("with at least the permission 'read_api', format is :")}
${gr("> xxxxxxxxxxxxxxxxxxxx")}
>`.trim();
const result = prompt(message);
assert(result !== null);
return result.trim();
}
function get_all_merge_requests(client: ApiClient) {
return client.fetch_parse_paginated(`api/v4/merge_requests`, merge_request_parser);
}
async function count_in_merge_request(merge_request: MergeRequest, client: ApiClient) {
const all_notes = get_all_merge_request_notes(merge_request, client);
let total = 0;
for await (const note of all_notes) {
if (!await is_relevant(note.author.username)) continue;
if (note.body.toLowerCase().includes("oki")) total += 1;
}
return total;
}
function get_all_merge_request_notes(merge_request: MergeRequest, client: ApiClient) {
const merge_request_path = `api/v4/projects/${merge_request.project_id}/merge_requests/${merge_request.iid}`;
return client.fetch_parse_paginated(`${merge_request_path}/notes`, note_parser);
}
if (import.meta.main) await main();