Całkowicie swobodna dyskusja
Wczoraj opisałem częściowe rozwiązanie problemu z komentarzami. Dzisiaj pora zająć się drugą częścią problemu i pozbyć się go raz na zawsze!
Co udało się dotąd osiągnąć?
Dla przypomnienia: problem z komentarzami polegał na tym, że nie istniały dyskusje dla wszystkich wpisów. Przez to osoby, które nie zaakceptowały ciasteczek, nie miały możliwości dodawania komentarzy.
Dlatego stworzyłem skrypt, który sprawdza, jakie dyskusje istnieją, a następnie tworzy wszystkie brakujące. W tym celu wykorzystałem APi GitHuba i personalny token dostępowy. Niemniej to rozwiązanie naprawia komentarze tylko już w istniejących wpisach. Jeśli teraz dodałbym nowy, w nim problem znowu by się pojawił. Więc musiałbym regularnie odpalać swój naprawiający dyskusje skrypt… Albo przygotować rozwiązanie, które dodaje dyskusje dla każdego nowego wpisu.
Na ratunek GitHub Actions!
Z racji tego, że moim hobby jest spędzanie kilku godzin na próbie automatyzacji 3-minutowych czynności, postanowiłem zautomatyzować dodawanie dyskusji dla każdego nowego wpisu. Na całe szczęście GitHub udostępnia narzędzie, które to zdecydowanie ułatwia – GitHub Actions. Jest to rozwiązanie typu CI/CD – Continuous Integration (Ciągła Integracja)/Continuous Delivery (Ciągłe Dostarczanie). Tego typu rozwiązania służą do monitoringu zmian w kodzie. Jeśli coś trafia do repozytorium, system CI/CD dba o to, by spełniało określone standardy. Odpali testy, wygeneruje dokumentację, a następnie może nawet automatycznie opublikować paczkę w npm-ie czy innym repozytorium pakietów. Słowem: automatyzuje te części procesu wypuszczania oprogramowania, które warto zautomatyzować.
GitHub Actions integrują się ze zdarzeniami, które mogą zajść w repozytorium na GitHubie, takimi jak dodanie nowego issue czy wypchnięcie zmian do konkretnego brancha. Dzięki temu można np. sprawdzić, czy osoba zgłaszająca błąd dodała potrzebne informacje, albo wypuścić nową wersję pakietu npm za każdym razem, gdy nowy kod trafi do głównego brancha. Można też… dodać nową dyskusję, gdy pojawi się nowy wpis!
Konfiguracja akcji
Żeby móc odpalić akcję, trzeba dodać do repozytorium odpowiedni plik konfiguracyjny, tzw. workflow. Jest to plik w formacie YAML, który zawiera informacje, jakie akcje chcemy odpalić oraz kiedy. Ten plik następnie umieszcza się w katalogu .github/workflows.
Dodajmy zatem plik .github/workflows/fix-discussions.yml:
name: Fix discussions # 1
permissions: # 2
discussions: write # 3
on: # 4
push:
branches:
- main #5
paths:
- 'src/_posts/**/*.md' #6
jobs: # 7
fix-discussions: # 8
runs-on: ubuntu-latest # 9
steps: # 10
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # 11
with:
fetch-depth: 2 # 12
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # 13
with:
node-version: 24 # 14
- run: npm install --no-save @octokit/action # 15
- run: node .github/actions/fix-discussions.mjs # 16
env:
GITHUB_TOKEN: $ # 17
CATEGORY_ID: '[ciach]' # 18
POSTS_DIR_PATH: 'src/_posts' # 19
BLOG_URL: 'https://blog.comandeer.pl' # 20
Na początku podajemy nazwę workflowu (1). Następnie określamy uprawnienia dla domyślnego tokena dostępowego (2). Każdy workflow posiada token, który jest wykorzystywany do autoryzacji wszystkich operacji. W naszym wypadku token musi umożliwiać dodawanie dyskusji (3), więc dostaje tylko takie uprawnienia. Następnie określamy, kiedy workflow ma się wykonać (4). Chcemy, żeby zachodził, gdy zmiany zostaną wypchnięte do brancha main (5) i do tego dotyczyć będą plików wpisów (6). Potem ustalamy, co tak naprawdę ma się stać, tworząc zadania – jobs (7). W naszym wypadku jest tylko jedno zadanie, fix-discussions (8). Odpalamy je na Linuksie (9). Następnie w steps (10) ustalamy poszczególne kroki zadania. Na początku korzystamy z akcji actions/checkout (11). Pobiera ona zawartość naszego repozytorium, pozwalając na nim pracować. W naszym przypadku dodatkowo musimy ustawić opcję fetch_depth na 2 (12). Domyślnie akcja pobiera tylko ostatni commit, a my potrzebujemy dwóch ostatnich (przy logice akcji ten wymóg się wyjaśni). Kolejnym krokiem jest wykorzystanie akcji actions/setup-node w celu instalacji Node.js (13). Zaznaczamy, że chcemy wersję 24 (14). Gdy już Node.js zostanie zainstalowany, instalujemy pakiet npm @octokit/action (15). To specjalna odmiana wykorzystywanej przez nas ostatnio biblioteki Octokit.js, dostosowana pod GitHub Actions. Na sam koniec uruchamiamy nasz skrypt JS (16), przekazując mu szereg zmiennych środowiskowych: token dostępowy GitHuba (17), ID kategorii dyskusji (18), względną ścieżkę do katalogu z wpisami (19) oraz URL bloga (20).
Dygresja
Jak można zauważyć, po nazwach wykorzystanych akcji znajduje się @<ciąg znaków>. Ten ciąg znaków wskazuje na konkretny commit w repozytorium akcji. Dzięki temu mamy pewność, że nasz kod zawsze użyje tej konkretnej wersji akcji. Innymi słowy: nawet jeśli w wyniku ataku ktoś wypuści nową wersję akcji, nasz kod powinien być bezpieczny, bo taką wersję zignoruje.
Logika akcji
Skrypt, będący “mózgiem” naszego workflowu, znajduje się w pliku .github/actions/fix-discussions.mjs. Jego główna logika prezentuje się następująco:
const octokit = new Octokit();
await main();
async function main() { // 1
const fullPostsDirPath = resolvePath( cwd(), env.POSTS_DIR_PATH );
const postFiles = getPostFiles( env.GITHUB_SHA, env.POSTS_DIR_PATH ); // 4
const posts = await getPostMetadata( postFiles ); // 2
const missingPosts = await getPostsWithoutDiscussions( { // 5
posts,
repository: env.GITHUB_REPOSITORY
} );
console.log( 'Brakujących dyskusji:', missingPosts.length );
await createMissingDiscussions( { // 3
repository: env.GITHUB_REPOSITORY,
categoryId: env.CATEGORY_ID,
posts: missingPosts,
blogUrl: env.BLOG_URL
} );
}
Całość zamknięta jest w asynchronicznej funkcji main() (1). W gruncie rzeczy jest ona bardzo podobna do wczorajszego kodu. Wewnątrz funkcji getPostMetadata() (2) została zamknięta pętla wyciągająca metadane dla każdego wpisu. Sam kod je wyciągający jest identyczny. Tak samo praktycznie bez zmian została funkcja tworząca nowe dyskusje, createMissingDiscussions() (3). Jedyną różnicą jest przekazywanie jej zmiennej środowiskowej GITHUB_REPOSITORY, zamiast osobno właściciela i nazwy repozytorium. Ta zmienna środowiskowa ma format <WŁAŚCICIEL>/<REPOZYTORIUM>, więc uzyskanie właściciela i nazwy sprowadza się do podzielenia wartości zmiennej na znaku /. Zmiany natomiast zaszły w funkcji pobierającej listę plików z wpisami (4) oraz w funkcji sprawdzającej, czy dla podanych postów istnieją dyskusje (5).
Przyjrzyjmy się najpierw funkcji getPostFiles():
import { execSync } from 'node:child_process'; // 2
[…]
function getPostFiles( commitHash, postsDirPath ) { // 1
const rawCommitInfo = execSync( `git diff-tree --no-commit-id --name-only ${ commitHash } -r` ); // 3
const commitInfo = rawCommitInfo.toString( 'utf-8' ); // 4
const lines = commitInfo.split( '\n' ); // 5
return lines // 8
.filter( ( line ) => {
const trimmedLine = line.trim();
return trimmedLine.startsWith( postsDirPath ) && trimmedLine.endsWith( '.md' ); // 6
} )
.map( ( path ) => {
return resolvePath( cwd(), path.trim() ); // 7
} );
}
Funkcja przyjmuje dwa argumenty (1): hash aktualnego commita oraz względną ścieżkę do katalogu z wpisami. Na sam początek wykorzystujemy funkcję execSync() z wbudowanego modułu node:child_process (2), aby wywołać komendę git diff-tree na przekazanym hashu commita (3). Komenda ta zwróci nam listę plików zmienionych w danym commicie. A że lista ta jest generowana na podstawie porównania tego commita z poprzednimi, stąd potrzeba wymuszenia na akcji actions/checkout pobrania dwóch ostatnich commitów (domyślnie pobiera tylko ostatni). Funkcja execSync() zwróci nam wynik w postaci bufora, więc musimy go skonwertować na ciąg znaków (4). Z racji tego, że git diff-tree wyświetla jedną nazwę pliku w linii, musimy podzielić wynik tej komendy na poszczególne linie (5). Następnie filtrujemy tablicę linii i znajdujemy tylko te, które dotyczą plików z wpisami (6). Następnie dla każdego wpisu rozwiązujemy pełną ścieżkę (7). Tak spreparowaną tablicę ścieżek zwracamy z funkcji (8).
Przejdźmy teraz do funkcji getPostsWithoutDiscussions():
async function getPostsWithoutDiscussions( options ) {
const postsWithoutDiscussions = []; // 1
for ( const post of options.posts ) { // 2
if ( await isMissingDiscussion( options.repository, post.slug ) ) { // 3
postsWithoutDiscussions.push( post ); // 4
}
await sleep( getRandomNumber( 1, 5 ) ); // 5
}
return postsWithoutDiscussions; // 6
}
Na początku tworzymy w niej tablicę wpisów, które nie mają dyskusji (1). Następnie iterujemy po wszystkich przekazanych postach (2) i sprawdzamy, czy każdy z nich ma swoją dyskusję (3). Jeśli nie, trafia do tablicy (4). Następnie odczekujemy losową liczbę sekund (5), żeby nie nadziać się na limity GitHub API. Jak już przerobimy wszystkie posty, zwracamy naszą tablicę (6).
Przyjrzyjmy się więc funkcji isMissingDiscussion():
async function isMissingDiscussion( repository, title ) {
const searchQuery = `repo:${ repository } in:title "${ title }"`; // 2
/* 1 */ const graphqlQuery = `
query($searchQuery: String!) {
search(query: $searchQuery, type: DISCUSSION, first: 1) {
edges {
node {
... on Discussion {
title
}
}
}
}
}
`;
const result = await octokit.graphql( graphqlQuery, {
searchQuery
} );
const discussions = result.search.edges.map( ( edge ) => {
return edge.node;
} );
return discussions.findIndex( ( discussion ) => {
return discussion.title === title; // 3
} ) === -1; // 4
}
Zawiera ona zapytanie GraphQL (1), które korzysta z wyszukiwarki GitHuba, żeby znaleźć dyskusję o konkretnym tytule. Samo zapytanie do wyszukiwarki (2) można… testować w wyszukiwarce GitHuba – składnia jest identyczna. Po otrzymaniu wyniku zapytania z API, sprawdzamy, czy znajduje się w nim rekord dla naszego wpisu (3). Jeśli tak, funkcja zwróci false. W innym wypadku zwróci true. Dzieje się tak, ponieważ metoda Array#findIndex() zwróci -1 (4) tylko wtedy, gdy szukanej rzeczy nie ma w tablicy.
I to w sumie tyle, reszta logiki skryptu działa praktycznie identycznie jak tego wczorajszego. Ale dzięki przeniesieniu tej logiki do akcji, dyskusje dla nowych wpisów powinny dodawać się automatycznie. Jak choćby dla tego wpisu!
A jeśli powyższy link jednak nie działa, to jutro zapewne kolejna część tej sagi…
Komentarze
Przejdź do komentarzy bezpośrednio na Githubie.