mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
Import -> GitHub -> Import a whole GitHub organization makes every repository of an organization or user its own board, opening in Board View / Version Control. Issues go through the ordinary importBoard, as the person who started the job, with their comments, labels and files. Releases are cards in a Releases list, their files attached only when chosen. Each .github/workflows file becomes Rules. The wiki is read from its git repository with isomorphic-git (1.42.4, pinned), every request through the SSRF guard and only to github.com: a page is a card in the Wiki list, its [[links]] point at and are attached to the linked pages' cards, and each commit that changed it is a dated edit in the card's history, so Restore brings back an older version. The job shows progress, can be cancelled, pauses on a restart and stops at a rate limit; the token is kept in memory only. Version Control gains Releases and Wiki tabs: the wiki as GitHub shows it, with the page list, _Sidebar and _Footer, links that stay in the tab, and each page's history with Restore. GitHub Actions workflows map to Rules: issue events to triggers, label, close, reopen, comment, assign and create steps to actions, actions/stale to an aging rule. Code - builds, tests, scripts - is never run: such a workflow is a disabled rule with its YAML as the description. WeKan's Rules gain what they lacked for it: a "commented on" trigger, an "add comment" action written as the person whose change ran the rule and only where they may comment, cron schedules in UTC, and a rule description shown on the rule. A disabled rule no longer fires on a schedule or from its button, which it did. The new strings are translated in every locale. Thanks to xet7 !
185 lines
8.6 KiB
JavaScript
185 lines
8.6 KiB
JavaScript
// A GitHub wiki, read from its git repository (<repo>.wiki.git: GitHub has no
|
|
// API for wikis), and written as cards - one page one card - by the GitHub
|
|
// organization import. What a page becomes is decided in
|
|
// models/lib/githubWiki.js (pure, tested); this reads the repository with
|
|
// isomorphic-git and writes the cards, their links and their history.
|
|
//
|
|
// Every request of the clone goes through the SSRF guard (fetchSafe), which
|
|
// validates and DNS-pins each hop, and may only reach the wiki's own server:
|
|
// isomorphic-git's own HTTP client is not used. The clone is made in a
|
|
// temporary directory that is removed afterwards, whatever happens.
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import { Random } from 'meteor/random';
|
|
import { fetchSafe } from '/server/lib/ssrfGuard';
|
|
import Cards from '/models/cards';
|
|
import Lists from '/models/lists';
|
|
import ChangeHistory from '/models/changeHistory';
|
|
|
|
const git = require('isomorphic-git');
|
|
const wiki = require('/models/lib/githubWiki');
|
|
const { prepareCardFieldHistory, persistSyncFieldHistory, SYNC_FIELDS } = require('/server/lib/syncHistoryBatch');
|
|
|
|
const MAX_WIKI_BYTES = 256 * 1024 * 1024;
|
|
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
|
|
const MAX_COMMITS = 5000;
|
|
|
|
// isomorphic-git's HTTP plugin, on the SSRF guard, for one origin only.
|
|
function guardedHttp(origin, token) {
|
|
return {
|
|
async request({ url, method = 'GET', headers = {}, body }) {
|
|
if (new URL(url).origin !== origin) throw new Error('the wiki clone tried to leave its server');
|
|
let data;
|
|
if (body) {
|
|
const chunks = [];
|
|
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
|
data = Buffer.concat(chunks);
|
|
}
|
|
const sent = { ...headers };
|
|
if (token) sent.Authorization = `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`;
|
|
const res = await fetchSafe(url, {
|
|
method, headers: sent, body: data, maxRedirects: 2,
|
|
timeoutMs: 120000, totalTimeoutMs: 300000, maxResponseBytes: MAX_WIKI_BYTES,
|
|
});
|
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
const out = {};
|
|
res.headers.forEach((value, key) => { out[key] = value; });
|
|
return { url, method, statusCode: res.status, statusMessage: res.statusText || '', headers: out, body: [new Uint8Array(buffer)] };
|
|
},
|
|
};
|
|
}
|
|
|
|
// The files of a commit's tree, as names. A name is only ever a string here:
|
|
// the clone has no working tree (noCheckout), nothing is written by name, and
|
|
// isomorphic-git writes nothing but its own object store under `dir`.
|
|
async function treeFiles(dir, oid, prefix = '') {
|
|
const { tree } = await git.readTree({ fs, dir, oid });
|
|
const files = [];
|
|
for (const node of tree) {
|
|
const name = prefix ? `${prefix}/${node.path}` : node.path;
|
|
if (node.type === 'tree') files.push(...await treeFiles(dir, node.oid, name));
|
|
else if (node.type === 'blob') files.push({ path: name, oid: node.oid });
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* The pages of a wiki with their whole history, or null when the repository
|
|
* has no wiki: [{ path, name, text, revisions: [{ commit, at, author, email,
|
|
* message, text }] }], revisions oldest first.
|
|
*/
|
|
export async function readGithubWiki({ owner, repo, token, host = 'https://github.com' }) {
|
|
const origin = new URL(host).origin;
|
|
const dir = path.join(process.env.WRITABLE_PATH || os.tmpdir(), 'wekan-wiki-import', Random.id());
|
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
try {
|
|
try {
|
|
await git.clone({ fs, http: guardedHttp(origin, token), dir, url: `${origin}/${owner}/${repo}.wiki.git`,
|
|
singleBranch: true, noCheckout: true, noTags: true });
|
|
} catch (error) {
|
|
// No wiki, or a wiki nobody wrote yet: GitHub answers 404 / 401.
|
|
if (/HttpError|404|401|403|NotFound/i.test(`${error && (error.code || error.name)} ${error && error.message}`)) return null;
|
|
throw error;
|
|
}
|
|
const commits = (await git.log({ fs, dir, depth: MAX_COMMITS })).reverse();
|
|
const last = new Map();
|
|
const revisions = new Map();
|
|
for (const c of commits) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const files = (await treeFiles(dir, c.commit.tree)).filter(f => wiki.pageNameFromPath(f.path));
|
|
const present = new Set(files.map(f => f.path));
|
|
const meta = {
|
|
commit: c.oid,
|
|
at: new Date(c.commit.author.timestamp * 1000),
|
|
author: c.commit.author.name,
|
|
email: c.commit.author.email,
|
|
message: c.commit.message.trim(),
|
|
};
|
|
for (const file of files) {
|
|
if (last.get(file.path) === file.oid) continue;
|
|
last.set(file.path, file.oid);
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const { blob } = await git.readBlob({ fs, dir, oid: file.oid });
|
|
const text = Buffer.from(blob).subarray(0, MAX_PAGE_BYTES).toString('utf8');
|
|
if (!revisions.has(file.path)) revisions.set(file.path, []);
|
|
revisions.get(file.path).push({ ...meta, text });
|
|
}
|
|
for (const [file] of last) {
|
|
if (present.has(file) || last.get(file) === null) continue;
|
|
last.set(file, null);
|
|
revisions.get(file).push({ ...meta, text: null });
|
|
}
|
|
}
|
|
const pages = [];
|
|
for (const [file, history] of revisions) {
|
|
const current = history[history.length - 1];
|
|
if (!current || current.text === null) continue; // deleted since
|
|
pages.push({ path: file, name: wiki.pageNameFromPath(file), text: current.text, revisions: history });
|
|
}
|
|
return wiki.sortPages(pages);
|
|
} finally {
|
|
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The pages as cards in the board's Wiki list: the title on the minicard,
|
|
* the text in the description with [[links]] pointing at the linked pages'
|
|
* cards and those cards attached ("link to card"), and every revision as
|
|
* an edit of the description in the card's history, dated as committed.
|
|
* Returns the number of pages written.
|
|
*/
|
|
export async function writeWikiCards({ board, swimlaneId, pages, userId, repoWikiUrl }) {
|
|
if (!pages || !pages.length) return 0;
|
|
let list = await Lists.findOneAsync({ boardId: board._id, title: wiki.WIKI_LIST_TITLE, archived: false });
|
|
if (!list) {
|
|
const lists = await Lists.find({ boardId: board._id }, { fields: { sort: 1 } }).fetchAsync();
|
|
const listId = await Lists.insertAsync({ title: wiki.WIKI_LIST_TITLE, boardId: board._id, archived: false,
|
|
sort: lists.reduce((m, l) => Math.max(m, Number(l.sort) || 0), -1) + 1, createdAt: new Date() });
|
|
list = await Lists.findOneAsync(listId);
|
|
}
|
|
const cardByKey = new Map();
|
|
for (let i = 0; i < pages.length; i += 1) {
|
|
const page = pages[i];
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const cardId = await Cards.insertAsync({
|
|
title: page.name, description: '', boardId: board._id, listId: list._id, swimlaneId,
|
|
userId, sort: i, cardNumber: await board.getNextCardNumber(),
|
|
});
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const card = await Cards.findOneAsync(cardId);
|
|
cardByKey.set(wiki.pageKey(page.name), { _id: cardId, url: card.originRelativeUrl(board), card });
|
|
}
|
|
const cardFor = key => cardByKey.get(key) || null;
|
|
for (const page of pages) {
|
|
const { card } = cardByKey.get(wiki.pageKey(page.name));
|
|
// The history first, oldest to newest, each revision with its links
|
|
// pointing at the cards; then the card holds the newest text.
|
|
let previous = '';
|
|
for (const edit of wiki.pageHistory(page.revisions)) {
|
|
const next = edit.next === null ? '' : wiki.rewriteWikiLinks(edit.next, { repoWikiUrl, cardFor }).text;
|
|
if (next === previous) continue;
|
|
const base = { _id: card._id, boardId: board._id, listId: list._id, swimlaneId };
|
|
const plan = prepareCardFieldHistory({
|
|
before: { ...base, description: previous }, after: { ...base, description: next },
|
|
effectId: crypto.createHash('sha256').update(`wiki:${card._id}:${edit.commit}`).digest('hex'),
|
|
userId, createdAt: new Date(edit.at), fields: SYNC_FIELDS,
|
|
});
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await persistSyncFieldHistory({ history: ChangeHistory, plan, assertCurrent: async () => {} });
|
|
previous = next;
|
|
}
|
|
const { text, linkedCardIds } = wiki.rewriteWikiLinks(page.text, { repoWikiUrl, cardFor });
|
|
// `.direct`: the history above already records this text as the newest
|
|
// edit; a hooked write would record it a second time, dated today.
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await Cards.direct.updateAsync(card._id, { $set: {
|
|
description: text,
|
|
...(linkedCardIds.length ? { attachedCardIds: linkedCardIds.filter(id => id !== card._id).slice(0, 100) } : {}),
|
|
} });
|
|
}
|
|
return pages.length;
|
|
}
|