Files
wekan/models/lib/githubOrgImport.js
Lauri Ojansivu 935e68dfdd GitHub organization import: repositories, issues, releases, Actions, wiki.
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 !
2026-10-14 12:43:36 +03:00

79 lines
4.3 KiB
JavaScript

'use strict';
// The GitHub organization import (server/githubOrgImport.js): every
// repository of an organization - or of a user - as its own board, viewed in
// Board View / Version Control. Pure: tests/githubOrgImport.test.cjs.
//
// issues the issues with their comments, labels, milestones and files,
// through the ordinary GitHub importer (one board per repository)
// releases a card per release in the board's Releases list: the tag and
// name as the title, the notes as the description, the date as
// received, and - only when chosen - the release files attached
// actions each .github/workflows file as Rules
// (models/lib/githubActionsRules.js)
// wiki each wiki page as a card in the Wiki list (models/lib/githubWiki.js)
const PARTS = ['issues', 'releases', 'releaseFiles', 'actions', 'wiki'];
const DEFAULT_PARTS = ['issues', 'releases', 'actions', 'wiki'];
const RELEASES_LIST_TITLE = 'Releases';
const MAX_REPOS = 1000;
// GitHub's own rules: a login is 1-39 letters, digits and hyphens; a
// repository name 1-100 letters, digits, '.', '_' and '-'.
const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const REPO = /^[A-Za-z0-9._-]{1,100}$/;
/** The owner of `https://github.com/wekan`, `github.com/wekan/` or `wekan`. */
function ownerFrom(input) {
const text = String(input || '').trim().replace(/\/+$/, '');
const m = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/?#]+)$/i.exec(text);
const owner = m ? m[1] : text;
return OWNER.test(owner) ? owner : null;
}
/** The import's options, cleaned, or throws with an i18n key. */
function cleanOrgImport(input) {
const owner = ownerFrom(input && input.owner);
if (!owner) throw new Error('github-org-import-owner-invalid');
const parts = Array.isArray(input.parts) ? [...new Set(input.parts.filter(p => PARTS.includes(p)))] : DEFAULT_PARTS.slice();
if (!parts.includes('issues')) parts.unshift('issues');
const repos = Array.isArray(input.repos) ? [...new Set(input.repos.map(String).filter(r => REPO.test(r)))] : null;
if (repos && !repos.length) throw new Error('github-org-import-no-repos');
return { owner, parts, repos, includeForks: input.includeForks === true, includeArchived: input.includeArchived !== false };
}
/** The repositories to import, in name order, from the API's repository list. */
function selectRepos(list, { owner, repos, includeForks, includeArchived }) {
const wanted = repos ? new Set(repos.map(r => r.toLowerCase())) : null;
return (Array.isArray(list) ? list : [])
.filter(r => r && typeof r.name === 'string' && REPO.test(r.name))
.filter(r => !r.owner || !r.owner.login || r.owner.login.toLowerCase() === owner.toLowerCase())
.filter(r => (wanted ? wanted.has(r.name.toLowerCase()) : (includeForks || !r.fork) && (includeArchived || !r.archived)))
.map(r => ({ name: r.name, description: r.description || '', archived: !!r.archived, fork: !!r.fork,
hasIssues: r.has_issues !== false, hasWiki: r.has_wiki !== false, private: !!r.private, url: r.html_url || '' }))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, MAX_REPOS);
}
/** A release as a card: [{ title, description, receivedAt, assets }]. */
function releaseCards(releases) {
return (Array.isArray(releases) ? releases : [])
.filter(r => r && (r.tag_name || r.name))
.map((r) => {
const tag = String(r.tag_name || '').trim();
const name = String(r.name || '').trim();
const title = name && name !== tag ? `${tag} ${name}`.trim() : (tag || name);
const flags = [r.draft ? 'draft' : null, r.prerelease ? 'pre-release' : null].filter(Boolean);
return {
title: flags.length ? `${title} (${flags.join(', ')})` : title,
description: [String(r.body || '').trim(), r.html_url ? `Source: ${r.html_url}` : ''].filter(Boolean).join('\n\n'),
receivedAt: r.published_at || r.created_at || null,
assets: (Array.isArray(r.assets) ? r.assets : [])
.filter(a => a && typeof a.browser_download_url === 'string' && /^https:\/\//.test(a.browser_download_url))
.map(a => ({ name: String(a.name || 'asset'), url: a.browser_download_url, size: Number(a.size) || 0 })),
};
});
}
module.exports = { PARTS, DEFAULT_PARTS, RELEASES_LIST_TITLE, MAX_REPOS, ownerFrom, cleanOrgImport, selectRepos, releaseCards };