├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ └── node.js.yml
├── doc
└── Tuttle-OpenAPI.png
├── .gitignore
├── src
├── resources
│ ├── images
│ │ ├── HPTuttle-1866.jpg
│ │ ├── HPTuttle-1866.png
│ │ ├── gitlab.svg
│ │ ├── github.svg
│ │ ├── Git_icon.svg
│ │ └── x.svg
│ └── css
│ │ ├── fore.css
│ │ ├── toastify.css
│ │ ├── styles.css
│ │ └── vars.css
├── repo.xml.tmpl
├── expath-pkg.xml.tmpl
├── cleanup.xq
├── api.html
├── finish.xq
├── modules
│ ├── vcs.xqm
│ ├── config.xqm
│ ├── gitlab.xqm
│ ├── app.xqm
│ ├── github.xqm
│ └── api.xq
├── controller.xq
├── data
│ └── tuttle-example-config.xml
├── content
│ ├── collection.xqm
│ └── callbacks.xqm
└── index.html
├── test
├── main.js
├── fixtures
│ ├── alt-repoxml-tuttle.xml
│ ├── alt-big-repo-tuttle.xml
│ ├── test.xqm
│ └── alt-tuttle.xml
├── util.js
├── gitlab.js
├── github.js
└── tuttle.js
├── .existdb.json
├── package.json
├── gulpfile.js
└── README.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: eeditiones
2 | custom: https://www.paypal.com/paypalme/eeditiones
3 |
--------------------------------------------------------------------------------
/doc/Tuttle-OpenAPI.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eeditiones/tuttle/HEAD/doc/Tuttle-OpenAPI.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | build
3 | node_modules
4 | .vscode
5 | .DS_Store
6 | *.xar
7 | src/data/test.xml
8 |
--------------------------------------------------------------------------------
/src/resources/images/HPTuttle-1866.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eeditiones/tuttle/HEAD/src/resources/images/HPTuttle-1866.jpg
--------------------------------------------------------------------------------
/src/resources/images/HPTuttle-1866.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eeditiones/tuttle/HEAD/src/resources/images/HPTuttle-1866.png
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Maintain dependencies for GitHub Actions
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: weekly
8 | - package-ecosystem: npm
9 | directory: "/"
10 | schedule:
11 | interval: weekly
12 |
--------------------------------------------------------------------------------
/src/resources/images/gitlab.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/test/main.js:
--------------------------------------------------------------------------------
1 | import { before, after } from 'node:test';
2 | // the tests depend on eachother. run the in the correct order.
3 | import github from './github.js';
4 | import gitlab from './gitlab.js';
5 | import tuttle from './tuttle.js';
6 | import { ensureTuttleIsInstalled, remove } from './util.js';
7 |
8 | before(ensureTuttleIsInstalled);
9 |
10 | await github();
11 | await gitlab();
12 | await tuttle();
13 |
14 | after(() => remove());
15 |
--------------------------------------------------------------------------------
/src/repo.xml.tmpl:
--------------------------------------------------------------------------------
1 |
2 |
3 | a Git Web API integration for eXist-db
4 | TEI Publisher Project Team
5 | https://github.com/eeditiones/tuttle
6 | stable
7 | GPL-3.0
8 | true
9 | application
10 | tuttle
11 | finish.xq
12 | cleanup.xq
13 |
14 |
--------------------------------------------------------------------------------
/src/expath-pkg.xml.tmpl:
--------------------------------------------------------------------------------
1 |
2 |
4 | @title@
5 |
6 |
7 |
8 |
9 | http://e-editiones.org/tuttle/callbacks
10 | callbacks.xqm
11 |
12 |
13 | http://existsolutions.com/modules/collection
14 | collection.xqm
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/cleanup.xq:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 |
3 | declare namespace sm="http://exist-db.org/xquery/securitymanager";
4 |
5 | (: TODO: $target is not set in cleanup phase :)
6 | declare variable $configuration-collection := "/db/apps/tuttle/data/";
7 | declare variable $backup-collection := "/db/tuttle-backup/";
8 | declare variable $configuration-filename := "tuttle.xml";
9 |
10 | (: backup tuttle configuration :)
11 | if (not(xmldb:collection-available($backup-collection)))
12 | then ((: move/copy to collection :)
13 | util:log("info", "Creating configuration backup collection"),
14 | xmldb:create-collection("/db", "tuttle-backup"),
15 | sm:chmod(xs:anyURI($backup-collection), "rwxr-x---")
16 | )
17 | else ()
18 | ,
19 | util:log("info", "Backing up configuration"),
20 | xmldb:move($configuration-collection, $backup-collection, $configuration-filename)
21 |
--------------------------------------------------------------------------------
/test/fixtures/alt-repoxml-tuttle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | github
5 | https://api.github.com/
6 | tuttle-sample-data
7 | eeditiones
8 | XXX
9 | [v2]
10 | admin
11 |
12 |
13 |
14 |
15 | existdb.json
16 | build.xml
17 | README.md
18 | .gitignore
19 | expath-pkg.xml.tmpl
20 | repo.xml.tmpl
21 | build.properties.xml
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/api.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Tuttle - Git-integration for eXist-db
5 |
6 |
7 |
8 |
9 |
10 |
11 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.existdb.json:
--------------------------------------------------------------------------------
1 | {
2 | "servers": {
3 | "localhost": {
4 | "server": "https://127.0.0.1:8443/exist",
5 | "user": "admin",
6 | "password": "",
7 | "root": "/db/apps/tuttle"
8 | }
9 | },
10 | "sync": {
11 | "server": "localhost",
12 | "ignore": [
13 | ".existdb.json",
14 | ".git/**",
15 | "node_modules/**",
16 | "bower_components/**",
17 | "package*.json",
18 | ".vscode/**"
19 | ]
20 | },
21 | "package": {
22 | "author": "TEI-publisher project team",
23 | "target": "tuttle",
24 | "description": "Synchronize data with git services",
25 | "namespace": "http://e-editiones.org/tuttle",
26 | "website": "https://github.com/eeditiones/tuttle",
27 | "status": "dev",
28 | "title": "tuttle - pull data from git APIs",
29 | "license": "GPLv3"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/resources/images/github.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/alt-big-repo-tuttle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | true
6 | github
7 | https://api.github.com/
8 | tuttle-sample-data
9 | eeditiones
10 | XXX
11 | [long-history]
12 | admin
13 |
14 |
15 |
16 |
17 | existdb.json
18 | build.xml
19 | README.md
20 | .gitignore
21 | expath-pkg.xml.tmpl
22 | repo.xml.tmpl
23 | build.properties.xml
24 |
25 |
31 |
32 |
--------------------------------------------------------------------------------
/test/fixtures/test.xqm:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 | (:
3 | : custom tuttle callback function for testing
4 | :)
5 | module namespace test="//test";
6 |
7 | (:
8 | the first argument is the collection configuration as a map
9 |
10 | example changes
11 |
12 | map {
13 | "del": [
14 | map { "path": "fileD", "success": true() }
15 | ],
16 | "new": [
17 | map { "path": "fileN1", "success": true() }
18 | map { "path": "fileN2", "success": true() }
19 | map { "path": "fileN3", "success": false(), "error": map{ "code": "err:XPTY0004", "description": "AAAAAAH!", "value": () } }
20 | ],
21 | "ignored": [
22 | map { "path": "fileD" }
23 | ]
24 | }
25 |
26 | each array member in del, new and ignored is a
27 |
28 | record action-result(
29 | "path": xs:string,
30 | "success": xs:boolean,
31 | "error"?: xs:error()
32 | )
33 | :)
34 | declare function test:test ($collection-config as map(*), $changes as map(*)) as item()* {
35 | map{
36 | "callback": "test:test",
37 | "arguments": map{
38 | "config": $collection-config,
39 | "changes": $changes
40 | }
41 | }
42 | };
--------------------------------------------------------------------------------
/src/finish.xq:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 |
3 | declare namespace sm="http://exist-db.org/xquery/securitymanager";
4 |
5 | (: the target collection into which the app is deployed :)
6 | declare variable $target external;
7 |
8 | declare variable $configuration-collection := $target || "/data/";
9 | declare variable $backup-collection := "/db/tuttle-backup/";
10 | declare variable $configuration-filename := "tuttle.xml";
11 |
12 | (: look for backed up tuttle configuration :)
13 | if (doc-available($backup-collection || $configuration-filename))
14 | then ((: move/copy to collection :)
15 | util:log("info", "Restoring tuttle configuration from backup."),
16 | xmldb:move($backup-collection, $configuration-collection, $configuration-filename),
17 | xmldb:remove($backup-collection)
18 | )
19 | else ((: copy example configuration when no backup was found :)
20 | util:log("info", "No previous tuttle configuration found."),
21 | xmldb:copy-resource(
22 | $configuration-collection, "tuttle-example-config.xml",
23 | $configuration-collection, $configuration-filename
24 | )
25 | )
26 | ,
27 | (: tighten security for configuration file :)
28 | sm:chmod(xs:anyURI($configuration-collection || $configuration-filename), "rw-r-----")
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tuttle",
3 | "version": "2.1.0",
4 | "description": "tuttle - a Git-integration for eXist-db",
5 | "type": "module",
6 | "scripts": {
7 | "start": "gulp",
8 | "test": "npm run build && node --test --test-reporter spec ./test/main.js",
9 | "test:watch": "node --test --watch ./test/main.js",
10 | "build": "gulp xar",
11 | "deploy": "gulp install",
12 | "copy": "gulp copyStatic"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "https://github.com/eeditiones/tuttle"
17 | },
18 | "license": "GPL-3.0",
19 | "devDependencies": {
20 | "@existdb/gulp-exist": "^5.0.0",
21 | "@existdb/gulp-replace-tmpl": "^1.0.4",
22 | "axios": "^1.10.0",
23 | "delete": "^1.1.0",
24 | "gulp": "^5.0.1",
25 | "gulp-rename": "^2.1.0",
26 | "gulp-zip": "^6.1.0",
27 | "slimdom": "^4.3.5"
28 | },
29 | "private": true,
30 | "app": {
31 | "author": "TEI-publisher project team",
32 | "abbrev": "tuttle",
33 | "description": "Synchronize data with git services",
34 | "namespace": "http://e-editiones.org/tuttle",
35 | "website": "https://github.com/eeditiones/tuttle",
36 | "status": "dev",
37 | "title": "tuttle - pull data from git APIs",
38 | "license": "GPLv3"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/resources/images/Git_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
16 |
--------------------------------------------------------------------------------
/test/fixtures/alt-tuttle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | github
5 | https://api.github.com/
6 | tuttle-sample-data
7 | eeditiones
8 | XXX
9 | [nonexistent]
10 | admin
11 |
12 |
13 |
14 |
15 | gitlab
16 | https://gitlab.com/api/v4/
17 | tuttle-sample-data
18 | XXX
19 | [master]
20 | admin
21 |
22 |
23 |
24 |
25 |
26 |
27 | existdb.json
28 | build.xml
29 | README.md
30 | .gitignore
31 | expath-pkg.xml.tmpl
32 | repo.xml.tmpl
33 | build.properties.xml
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Test
5 |
6 | on:
7 | push:
8 | schedule:
9 | # weekly runs mondays at 08:40
10 | - cron: "40 8 * * 1"
11 |
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 | continue-on-error: ${{ matrix.experimental }}
17 | strategy:
18 | fail-fast: false
19 | matrix:
20 | exist-version: [release, 5.5.1]
21 | experimental: [false]
22 | include:
23 | - exist-version: latest
24 | experimental: true
25 | services:
26 | # Label used to access the service container
27 | exist:
28 | env:
29 | tuttle_token_tuttle_sample_data: ${{ secrets.TUTTLE_TEST_TOKEN }}
30 | tuttle_token_tuttle_sample_gitlab: ${{ secrets.GITLAB_READ_TOKEN }}
31 | image: existdb/existdb:${{ matrix.exist-version }}
32 | ports:
33 | - 8443:8443
34 | volumes:
35 | # point autodeploy to a folder without any XARs
36 | - ${{ github.workspace }}/doc:/exist/autodeploy
37 | options: >-
38 | --health-interval 4s
39 | steps:
40 | - uses: actions/checkout@v4
41 | - uses: actions/setup-node@v4
42 | with:
43 | node-version: 22
44 | - run: npm ci
45 | - name: run tests
46 | run: npm test
47 |
48 |
--------------------------------------------------------------------------------
/src/resources/css/fore.css:
--------------------------------------------------------------------------------
1 | @import 'toastify.css';
2 | [unresolved]{
3 | display: none;
4 | }
5 | [disabled] {
6 | pointer-events: none;
7 | cursor: default;
8 | }
9 | fx-trigger a[disabled] {
10 | color:lightgrey;
11 | }
12 | fx-trigger img[disabled]{
13 | filter:blur(2px);
14 | }
15 |
16 | .error{
17 | background: var(--paper-red-500);
18 | }
19 | fx-alert{
20 | color:darkred;
21 | font-size: 0.9rem;
22 | }
23 |
24 | fx-model, fx-model *, fx-model ::slotted(fx-instance), fx-instance{
25 | display:none;
26 | }
27 |
28 | fx-control, fx-trigger{
29 | white-space: nowrap;
30 | position: relative;
31 | }
32 | fx-hint{
33 | display: none;
34 | }
35 | fx-repeatitem{
36 | position:relative;
37 | /*
38 | opacity:1;
39 | -webkit-transition: opacity 3s;
40 | -moz-transition: opacity 3s;
41 | transition: opacity 3s;
42 | */
43 | }
44 | .hidden {
45 | visibility: hidden;
46 | opacity: 0;
47 | transition: visibility 0s 2s, opacity 2s linear;
48 | }
49 | /*
50 | .visible {
51 | visibility: visible;
52 | opacity: 1;
53 | transition: opacity 2s linear;
54 | }
55 | */
56 |
57 | .required:after {
58 | content: '*';
59 | color: red;
60 | padding-left: 4px;
61 | right:3px;
62 | top:3px;
63 | z-index: 1;
64 | }
65 |
66 | [required]:after {
67 | content: "*";
68 | display: inline;
69 | color: red;
70 | }
71 | .logtree details{
72 | padding:0.1rem 1rem;
73 | margin:0;
74 | }
75 | .logtree details summary{
76 |
77 | }
78 | .non-relevant{
79 | opacity: 0;
80 | height: 0;
81 | transition: opacity 1s;
82 | }
83 | .vertical label{
84 | display: block;
85 | }
86 |
87 |
--------------------------------------------------------------------------------
/src/modules/vcs.xqm:
--------------------------------------------------------------------------------
1 |
2 | module namespace vcs="http://e-editiones.org/tuttle/vcs";
3 |
4 | (: all API adapter modules must be imported here :)
5 | import module namespace github="http://e-editiones.org/tuttle/github" at "github.xqm";
6 | import module namespace gitlab="http://e-editiones.org/tuttle/gitlab" at "gitlab.xqm";
7 |
8 | (: each enabled API must be listed here :)
9 | declare variable $vcs:mappings as map(*) := map {
10 | "github": map {
11 | "get-url": github:get-url#1,
12 | "get-archive": github:get-archive#2,
13 | "get-last-commit": github:get-last-commit#1,
14 | "get-specific-commit": github:get-specific-commit#2,
15 | "get-commits": github:get-commits#2,
16 | "get-all-commits": github:get-commits#1,
17 | "incremental-dry": github:incremental-dry#1,
18 | "incremental": github:incremental#1,
19 | "check-signature": github:check-signature#2
20 | },
21 | "gitlab": map {
22 | "get-url": gitlab:get-url#1,
23 | "get-archive": gitlab:get-archive#2,
24 | "get-last-commit": gitlab:get-last-commit#1,
25 | "get-specific-commit": gitlab:get-specific-commit#2,
26 | "get-commits": gitlab:get-commits#2,
27 | "get-all-commits": gitlab:get-commits#1,
28 | "incremental-dry": gitlab:incremental-dry#1,
29 | "incremental": gitlab:incremental#1,
30 | "check-signature": gitlab:check-signature#2
31 | }
32 | };
33 |
34 | declare variable $vcs:supported-services as xs:string+ := map:keys($vcs:mappings);
35 |
36 | declare function vcs:is-known-service ($vcs as xs:string?) as xs:boolean {
37 | exists($vcs) and $vcs = $vcs:supported-services
38 | };
39 |
40 | declare function vcs:get-actions ($vcs as xs:string?) as map(*)? {
41 | if (vcs:is-known-service($vcs))
42 | then $vcs:mappings?($vcs)
43 | else error((), "Unknown VCS: '" || $vcs || "'")
44 | };
45 |
--------------------------------------------------------------------------------
/src/resources/css/toastify.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Toastify js 1.11.0
3 | * https://github.com/apvarun/toastify-js
4 | * @license MIT licensed
5 | *
6 | * Copyright (C) 2018 Varun A P
7 | */
8 |
9 | .toastify {
10 | padding: 12px 20px;
11 | color: #ffffff;
12 | display: inline-block;
13 | box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3);
14 | background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5);
15 | background: linear-gradient(135deg, #73a5ff, #5477f5);
16 | position: fixed;
17 | opacity: 0;
18 | transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
19 | border-radius: 2px;
20 | cursor: pointer;
21 | text-decoration: none;
22 | max-width: calc(50% - 20px);
23 | z-index: 2147483647;
24 | }
25 |
26 | .toastify.on {
27 | opacity: 1;
28 | }
29 |
30 | .toast-close {
31 | opacity: 0.8;
32 | padding: 0 5px;
33 | }
34 |
35 | .toastify-right {
36 | right: 15px;
37 | }
38 | .toastify-right .toast-close {
39 | margin-right:-10px;
40 | }
41 |
42 | .toastify-left {
43 | left: 15px;
44 | }
45 | .toastify-left .toast-close{
46 | margin-left:-10px;
47 | }
48 |
49 | .toastify-top {
50 | top: -150px;
51 | }
52 |
53 | .toastify-bottom {
54 | bottom: -150px;
55 | }
56 |
57 | .toastify-rounded {
58 | border-radius: 25px;
59 | }
60 |
61 | .toastify-avatar {
62 | width: 1.5em;
63 | height: 1.5em;
64 | margin: -7px 5px;
65 | border-radius: 2px;
66 | }
67 |
68 | .toastify-center {
69 | margin-left: auto;
70 | margin-right: auto;
71 | left: 0;
72 | right: 0;
73 | max-width: fit-content;
74 | max-width: -moz-fit-content;
75 | }
76 |
77 | @media only screen and (max-width: 360px) {
78 | .toastify-right, .toastify-left {
79 | margin-left: auto;
80 | margin-right: auto;
81 | left: 0;
82 | right: 0;
83 | max-width: fit-content;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/controller.xq:
--------------------------------------------------------------------------------
1 | xquery version "3.0";
2 | import module namespace login="http://exist-db.org/xquery/login" at "resource:org/exist/xquery/modules/persistentlogin/login.xql";
3 |
4 | declare variable $exist:path external;
5 | declare variable $exist:resource external;
6 | declare variable $exist:controller external;
7 | declare variable $exist:prefix external;
8 | declare variable $exist:root external;
9 |
10 | declare variable $is-get := lower-case(request:get-method()) eq 'get';
11 |
12 | if ($is-get and $exist:path eq "") then
13 |
14 |
15 |
16 |
17 | else if ($is-get and $exist:path eq "/") then
18 |
19 |
20 |
21 |
22 |
23 |
24 | else if ($exist:resource eq 'login') then
25 | let $loggedIn := login:set-user("org.exist.login", (), false())
26 | let $user := request:get-attribute("org.exist.login.user")
27 | return (
28 | util:declare-option("exist:serialize", "method=json"),
29 | try {
30 |
31 | {$user}
32 | {
33 | if ($user) then (
34 | for $item in sm:get-user-groups($user) return {$item},
35 | {sm:is-dba($user)}
36 | ) else
37 | ()
38 | }
39 |
40 | } catch * {
41 | response:set-status-code(401),
42 | {$err:description}
43 | }
44 | )
45 |
46 | (: static HTML page for API documentation should be served directly to make sure it is always accessible :)
47 | else if ($is-get and $exist:path = ("/api.html", "/api.json")) then
48 |
49 |
50 | (: serve static resources :)
51 | else if ($is-get and matches($exist:path, "^/(css|js|images)/[^/]+\.(css|js(\.map)?|svg|jpg|png)$")) then
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | (: all other requests are passed on the Open API router :)
60 | else
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/src/data/tuttle-example-config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 | true
8 |
9 |
10 | github
11 | https://api.github.com/
12 |
13 |
14 | eeditiones
15 | tuttle-sample-data
16 |
17 |
18 | [next]
19 |
20 |
22 | XXX
23 |
24 |
25 | admin
26 |
27 |
28 |
33 |
34 |
35 |
38 |
41 |
42 |
43 |
44 |
45 | gitlab
46 | https://gitlab.com/api/v4/
47 |
48 |
49 | line-o
50 | tuttle-sample-data
51 |
52 |
53 | 50872175
54 |
55 |
56 | [main]
57 |
58 |
60 | XXX
61 |
62 |
63 | admin
64 |
65 |
66 |
67 |
68 |
69 |
70 | existdb.json
71 | build.xml
72 | README.md
73 | .gitignore
74 | expath-pkg.xml.tmpl
75 | repo.xml.tmpl
76 | build.properties.xml
77 |
78 |
79 |
80 |
81 |
85 |
86 |
87 |
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | /**
2 | * an example gulpfile to make ant-less existdb package builds a reality
3 | */
4 | import { src, dest, watch, series, parallel, lastRun } from 'gulp'
5 | import { createClient } from '@existdb/gulp-exist'
6 | import replace from '@existdb/gulp-replace-tmpl'
7 | import zip from 'gulp-zip'
8 | import rename from 'gulp-rename'
9 | import del from 'delete'
10 |
11 | import pkg from './package.json' with { type: 'json' }
12 | const { app, version, license } = pkg
13 | const replacements = [app, { version, license }]
14 |
15 | const packageUri = app.namespace
16 |
17 | // read metadata from .existdb.json
18 | import existJSON from './.existdb.json' with { type: 'json' }
19 | const serverInfo = existJSON.servers.localhost
20 | const url = new URL(serverInfo.server)
21 | const connectionOptions = {
22 | host: url.hostname,
23 | port: url.port,
24 | secure: url.protocol === 'https:',
25 | basic_auth: {
26 | user: serverInfo.user,
27 | pass: serverInfo.password
28 | }
29 | }
30 | const existClient = createClient(connectionOptions);
31 |
32 | /**
33 | * Use the `delete` module directly, instead of using gulp-rimraf
34 | */
35 | function clean (cb) {
36 | del(['build', 'dist'], cb);
37 | }
38 |
39 | /**
40 | * replace placeholders
41 | * in src/repo.xml.tmpl and
42 | * output to build/repo.xml
43 | */
44 | function templates () {
45 | return src('src/*.tmpl')
46 | .pipe(replace(replacements, {unprefixed:true}))
47 | .pipe(rename(path => { path.extname = "" }))
48 | .pipe(dest('build/'))
49 | }
50 |
51 | function watchTemplates () {
52 | watch('src/*.tmpl', series(templates))
53 | }
54 |
55 |
56 | const staticFiles = 'src/**/*.{xml,html,xq,xqm,xsl,xconf,json,svg,js,css,map}'
57 | const images = 'src/**/*.{png,jpg}'
58 |
59 | /**
60 | * copy html templates, XSL stylesheet, XMLs and XQueries to 'build'
61 | */
62 | function copyStatic () {
63 | return src(staticFiles).pipe(dest('build'))
64 | }
65 |
66 | function watchStatic () {
67 | watch(staticFiles, series(copyStatic));
68 | }
69 |
70 | /**
71 | * copy images to 'build'
72 | */
73 | function copyImages () {
74 | return src(images, {encoding: false}).pipe(dest('build'))
75 | }
76 |
77 | function watchImages () {
78 | watch(images, series(copyImages));
79 | }
80 |
81 | /**
82 | * Upload all files in the build folder to existdb.
83 | * This function will only upload what was changed
84 | * since the last run (see gulp documentation for lastRun).
85 | */
86 | function deployApp () {
87 | return src('build/**/*', {
88 | base: 'build/',
89 | since: lastRun(deploy)
90 | })
91 | .pipe(existClient.dest({target}))
92 | }
93 |
94 | function watchBuild () {
95 | watch('build/**/*', series(deploy))
96 | }
97 |
98 | // construct the current xar name from available data
99 | const xarFilename = `${app.abbrev}-${version}.xar`
100 |
101 | /**
102 | * create XAR package in repo root
103 | */
104 | function createXar () {
105 | return src('build/**/*', {base: 'build/', encoding: false})
106 | .pipe(zip(xarFilename))
107 | .pipe(dest('dist'))
108 | }
109 |
110 | /**
111 | * upload and install the latest built XAR
112 | */
113 | function installXar () {
114 | return src(xarFilename, {cwd:'dist/', encoding: false})
115 | .pipe(existClient.install({ packageUri }))
116 | }
117 |
118 |
119 | // composed tasks
120 | const build = series(
121 | clean,
122 | templates,
123 | copyStatic,
124 | copyImages
125 | )
126 | const deploy = series(build, deployApp)
127 | const watchAll = parallel(
128 | watchStatic,
129 | watchImages,
130 | watchTemplates,
131 | watchBuild
132 | )
133 |
134 | const xar = series(build, createXar)
135 | const install = series(build, xar, installXar)
136 |
137 | export {
138 | clean,
139 | templates,
140 | watchTemplates,
141 | copyStatic,
142 | watchStatic,
143 | copyImages,
144 | watchImages,
145 | build,
146 | deploy,
147 | xar,
148 | install,
149 | watchAll as watch,
150 | }
151 |
152 | // main task for day to day development
153 | export default series(build, deploy, watchAll)
--------------------------------------------------------------------------------
/src/content/collection.xqm:
--------------------------------------------------------------------------------
1 | xquery version '3.1';
2 |
3 | module namespace collection="http://existsolutions.com/modules/collection";
4 |
5 | import module namespace sm="http://exist-db.org/xquery/securitymanager";
6 | import module namespace xmldb="http://exist-db.org/xquery/xmldb";
7 |
8 | (:~
9 | : create arbitrarily deep-nested sub-collection
10 | : @param $new-collection absolute path that starts with "/db"
11 | : the string can have a slash at the end
12 | : @returns map(*) with xs:boolean success, xs:string path and xs:string error,
13 | : if something went wrong error contains the description and path is
14 | : the collection where the error occurred
15 | ~:)
16 | declare
17 | function collection:create ($path as xs:string) as map(*) {
18 | if (not(starts-with($path, '/db')))
19 | then (
20 | map {
21 | 'success': false(),
22 | 'path': $path,
23 | 'error': 'New collection must start with /db'
24 | }
25 | )
26 | else (
27 | fold-left(
28 | tail(tokenize($path, '/')),
29 | map { 'success': true(), 'path': '' },
30 | collection:fold-collections#2
31 | )
32 | )
33 | };
34 |
35 | declare
36 | %private
37 | function collection:fold-collections ($result as map(*), $next as xs:string*) as map(*) {
38 | let $path := concat($result?path, '/', $next)
39 |
40 | return
41 | if (not($result?success))
42 | then ($result)
43 | else if (xmldb:collection-available($path))
44 | then (map { 'success': true(), 'path': $path })
45 | else (
46 | try {
47 | map {
48 | 'success': exists(xmldb:create-collection($result?path, $next)),
49 | 'path': $path
50 | }
51 | }
52 | catch * {
53 | map {
54 | 'success': false(),
55 | 'path': $path,
56 | 'error': $err:description
57 | }
58 | }
59 | )
60 | };
61 |
62 | declare function collection:remove($path as xs:string, $force as xs:boolean) {
63 | if (not(xmldb:collection-available($path)))
64 | then true()
65 | else if ($force)
66 | then xmldb:remove($path)
67 | else if (not(empty((
68 | xmldb:get-child-resources($path),
69 | xmldb:get-child-collections($path)
70 | ))))
71 | then error(xs:QName("collection:not-empty"), "Collection '" || $path || "' is not empty and $force is false().")
72 | else xmldb:remove($path)
73 | };
74 |
75 | (:~
76 | : Scan a collection tree recursively starting at $root. Call the supplied function once for each
77 | : resource encountered. The first parameter to $func is the collection URI, the second the resource
78 | : path (including the collection part).
79 | :)
80 | declare function collection:scan($root as xs:string, $func as function(xs:string, xs:string?) as item()*) {
81 | collection:scan-collection($root, $func)
82 | };
83 |
84 | (:~ Scan a collection tree recursively starting at $root. Call $func once for each collection found :)
85 | declare %private function collection:scan-collection($collection as xs:string, $func as function(xs:string, xs:string?) as item()*) {
86 | $func($collection, ()),
87 | collection:scan-resources($collection, $func),
88 | for $child-collection in xmldb:get-child-collections($collection)
89 | let $path := concat($collection, "/", $child-collection)
90 | return
91 | if (sm:has-access(xs:anyURI($path), "rx")) then (
92 | collection:scan-collection($path, $func)
93 | ) else ()
94 | };
95 |
96 | (:~
97 | : List all resources contained in a collection and call the supplied function once for each
98 | : resource with the complete path to the resource as parameter.
99 | :)
100 | declare %private function collection:scan-resources($collection as xs:string, $func as function(xs:string, xs:string?) as item()*) {
101 | for $child-resource in xmldb:get-child-resources($collection)
102 | let $path := concat($collection, "/", $child-resource)
103 | return
104 | if (sm:has-access(xs:anyURI($path), "r")) then (
105 | $func($collection, $path)
106 | ) else ()
107 | };
108 |
--------------------------------------------------------------------------------
/test/util.js:
--------------------------------------------------------------------------------
1 | import { Agent } from 'node:https';
2 | import { readFile, readdir } from 'node:fs/promises';
3 | import { join, basename } from 'node:path';
4 | import { after, before } from 'node:test';
5 |
6 | import axios from 'axios';
7 | import { connect } from '@existdb/node-exist';
8 |
9 | import existJson from '../.existdb.json' with { type: 'json' };
10 | const { user, password, server } = existJson.servers.localhost;
11 |
12 | import pkg from '../package.json' with { type: 'json' };
13 | const { namespace } = pkg.app;
14 |
15 | // for use in custom controller tests
16 | const adminCredentials = { username: user, password };
17 |
18 | // read connction options from ENV
19 | if (process.env.EXISTDB_USER && 'EXISTDB_PASS' in process.env) {
20 | adminCredentials.username = process.env.EXISTDB_USER;
21 | adminCredentials.password = process.env.EXISTDB_PASS;
22 | }
23 |
24 | const testServer = 'EXISTDB_SERVER' in process.env ? process.env.EXISTDB_SERVER : server;
25 |
26 | const { origin, hostname, port, protocol } = new URL(testServer);
27 |
28 | const axiosInstanceOptions = {
29 | baseURL: `${origin}/exist/apps/tuttle`,
30 | headers: { Origin: origin },
31 | withCredentials: true,
32 | };
33 |
34 | const rejectUnauthorized = !(hostname === 'localhost' || hostname === '127.0.0.1');
35 | const secure = protocol === 'https:';
36 |
37 | if (secure) {
38 | axiosInstanceOptions.httpsAgent = new Agent({ rejectUnauthorized });
39 | }
40 |
41 | const axiosInstance = axios.create(axiosInstanceOptions);
42 |
43 | const db = connect({
44 | host: hostname,
45 | port,
46 | secure,
47 | rejectUnauthorized,
48 | basic_auth: {
49 | user: adminCredentials.username,
50 | pass: adminCredentials.password,
51 | },
52 | });
53 |
54 | async function putResource(buffer, path) {
55 | const fh = await db.documents.upload(buffer);
56 | return await db.documents.parseLocal(fh, path, {});
57 | }
58 |
59 | function getResourceInfo(resource) {
60 | return db.resources.describe(resource);
61 | }
62 |
63 | /**
64 | * @param {string} resource
65 | * @returns {Promise}
66 | */
67 | function getResource(resource) {
68 | return db.documents.read(resource, {});
69 | }
70 |
71 | async function install() {
72 | const matches = (await readdir('dist')).filter((entry) => entry.endsWith('.xar'));
73 |
74 | if (matches.length > 1) {
75 | throw new Error(`Multiple tuttle versions: ${matches}`);
76 | }
77 | if (matches.length === 0) {
78 | throw new Error(`No tuttle.xar found. Run 'npm build' before running tests`);
79 | }
80 |
81 | const xarFile = join('dist', matches[0]);
82 | const xarContents = await readFile(xarFile);
83 | const xarName = basename(xarFile);
84 | console.log('Uploading tuttle');
85 | await db.app.upload(xarContents, xarName);
86 | console.log('Uploaded tuttle, installing');
87 |
88 | await db.app.install(xarName);
89 | }
90 |
91 | async function remove() {
92 | console.log('removing');
93 | await db.app.remove(namespace);
94 |
95 | const result = await Promise.allSettled([
96 | db.collections.remove('/db/tuttle-backup'),
97 | db.collections.remove('/db/pkgtmp'),
98 |
99 | db.collections.remove('/db/apps/tuttle-sample-data'),
100 | db.collections.remove('/db/apps/tuttle-sample-gitlab'),
101 | ]);
102 |
103 | if (result.some((r) => r.status === 'rejected')) {
104 | console.warn(
105 | 'clean up failed',
106 | result.filter((r) => r.status === 'rejected').map((r) => r.reason),
107 | );
108 | }
109 | }
110 |
111 | /**
112 | * @type {Promise void>}
113 | */
114 | let tuttleInstallationPromise = null;
115 | let tuttleIsInstalled = false;
116 |
117 | async function ensureTuttleIsInstalled() {
118 | if (!tuttleInstallationPromise) {
119 | console.log('installing tuttle', tuttleInstallationPromise, tuttleIsInstalled);
120 |
121 | tuttleInstallationPromise = remove().then(() => install());
122 | await tuttleInstallationPromise;
123 | tuttleIsInstalled = true;
124 | return;
125 | }
126 | console.log('Not installing tuttle twice');
127 |
128 | console.log('Waiting until tuttle install is done');
129 | await tuttleInstallationPromise;
130 | console.log('done installing tuttle');
131 | }
132 |
133 | export {
134 | axiosInstance as axios,
135 | adminCredentials as auth,
136 | getResourceInfo,
137 | putResource,
138 | getResource,
139 | ensureTuttleIsInstalled,
140 | remove,
141 | install,
142 | };
143 |
--------------------------------------------------------------------------------
/src/resources/images/x.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/content/callbacks.xqm:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 | (:~
3 | Default callbacks
4 |
5 | callbacks must have the signature
6 | function(map(*), map(*)) as item()*
7 | :)
8 | module namespace cb="http://e-editiones.org/tuttle/callbacks";
9 |
10 | import module namespace collection="http://existsolutions.com/modules/collection";
11 |
12 | declare namespace expath="http://expath.org/ns/pkg";
13 |
14 | declare variable $cb:package-descriptor := "expath-pkg.xml";
15 | declare variable $cb:package-meta-files := ("expath-pkg.xml", "repo.xml", "exist.xml");
16 |
17 | declare variable $cb:temp-collection := "/db/system/repo";
18 |
19 | (:~
20 | : example callback
21 | : It will just return the arguments the function is called with
22 | : for documentation and testing purposes
23 | :
24 | : the first argument is the collection configuration as a map
25 | : the second argument is a report of the changes that were applied
26 | : example changes
27 |
28 | map {
29 | "del": [
30 | map { "path": "fileD", "success": true() }
31 | ],
32 | "new": [
33 | map { "path": "fileN1", "success": true() }
34 | map { "path": "fileN2", "success": true() }
35 | map { "path": "fileN3", "success": false(), "error": map{ "code": "err:XPTY0004", "description": "AAAAAAH!", "value": () } }
36 | ],
37 | "ignored": [
38 | map { "path": "fileD" }
39 | ]
40 | }
41 |
42 | : each array member in del, new and ignored is a
43 |
44 | record action-result(
45 | "path": xs:string,
46 | "success": xs:boolean,
47 | "error"?: xs:error()
48 | )
49 | :)
50 | declare function cb:test ($collection-config as map(*), $changes as map(*)) {
51 | map{
52 | "callback": "cb:test",
53 | "arguments": map{
54 | "config": $collection-config,
55 | "changes": $changes
56 | }
57 | }
58 | };
59 |
60 | (:~
61 | : Scan an array like ?new ?del or ?ignored you can find out if it contains a specific path(s)
62 | : if given more than one path then you need to think of them as being combined with or
63 | : Example:
64 | : to check if the repo.xml was added or changed do
65 | : cb:changes-array-contains-path($changes?new, "repo.xml")
66 | :)
67 | declare function cb:changes-array-contains-path($array as array(*), $path as xs:string+) as xs:boolean {
68 | $path = $array?*?path
69 | };
70 |
71 | (:~
72 | : Scan the changeset for an updated expath-pkg.xml
73 | : update the version that exist-db reports for this package by
74 | : "installing" a stub
75 | :)
76 | declare function cb:check-version ($collection-config as map(*), $changes as map(*)) as xs:string {
77 | if (not(cb:changes-array-contains-path($changes?new, $cb:package-descriptor))) then (
78 | "Descriptor unchanged"
79 | ) else if (not(doc-available($collection-config?path || "/" || $cb:package-descriptor))) then (
80 | error(xs:QName("cb:descriptor-missing"), "Package descriptor does not exist even though it was updated")
81 | ) else (
82 | let $expath-package-meta := doc($collection-config?path || "/" || $cb:package-descriptor)/expath:package
83 | let $new-version := $expath-package-meta/@version/string()
84 | let $old-version := cb:installed-version($expath-package-meta/@name)
85 |
86 | return
87 | if ($new-version eq $old-version) then (
88 | "Version unchanged"
89 | ) else if ($old-version) then (
90 | (
91 | repo:remove($expath-package-meta/@name),
92 | cb:update-package-version($collection-config?path, $expath-package-meta),
93 | "Updated from " || $old-version || " to " || $new-version
94 | )[3]
95 | ) else (
96 | cb:update-package-version($collection-config?path, $expath-package-meta),
97 | "Version set to " || $new-version
98 | )
99 | )
100 | };
101 |
102 |
103 | declare function cb:update-package-version ($target-collection as xs:string, $expath-package-meta as element(expath:package)) {
104 | let $package-name := $expath-package-meta/@name/string()
105 | let $stub-name := concat($expath-package-meta/@abbrev, "-", $expath-package-meta/@version, "__stub.xar")
106 | let $xar := cb:create-stub-package($target-collection, $stub-name)
107 |
108 | return cb:install-stub-package($stub-name)
109 | };
110 |
111 | declare function cb:installed-version($package-name as xs:string) as xs:string? {
112 | if (not($package-name = repo:list())) then (
113 | ) else (
114 | parse-xml(
115 | util:binary-to-string(
116 | repo:get-resource($package-name, $cb:package-descriptor)))
117 | /expath:package/@version/string()
118 | )
119 | };
120 |
121 | declare %private function cb:create-stub-package($collection as xs:string, $filename as xs:string) {
122 | (: ensure temp collection exists :)
123 | let $_ := collection:create($cb:temp-collection)
124 | let $contents := compression:zip(cb:resources-to-zip($collection), true(), $collection)
125 | return
126 | xmldb:store($cb:temp-collection, $filename, $contents, "application/zip")
127 | };
128 |
129 | declare %private function cb:resources-to-zip($collection as xs:string) {
130 | for $resource in (
131 | xmldb:get-child-resources($collection)[. = $cb:package-meta-files or starts-with(., "icon")]
132 | )
133 | return
134 | xs:anyURI($collection || "/" || $resource)
135 | };
136 |
137 | declare %private function cb:install-stub-package($xar-name as xs:string) {
138 | repo:install-from-db($cb:temp-collection || "/" || $xar-name),
139 | xmldb:remove($cb:temp-collection, $xar-name)
140 | };
141 |
--------------------------------------------------------------------------------
/test/gitlab.js:
--------------------------------------------------------------------------------
1 | import { axios, auth, getResourceInfo, ensureTuttleIsInstalled } from './util.js';
2 | import { describe, it, before } from 'node:test';
3 | import assert from 'node:assert';
4 |
5 | export default () =>
6 | describe('Gitlab', async function () {
7 | before(async () => {
8 | await ensureTuttleIsInstalled();
9 | });
10 | const testHASH = '79789e5c4842afaaa63c733c3ed6babe37f70121';
11 | const collection = 'tuttle-sample-gitlab';
12 |
13 | it('Remove lockfile', async function () {
14 | const resultPromise = axios.post(`git/${collection}/lockfile`, {}, { auth });
15 | await assert.doesNotReject(resultPromise, 'The request should succeed');
16 | const res = await resultPromise;
17 | assert.strictEqual(res.status, 200);
18 | });
19 |
20 | it('Get changelog', async function () {
21 | const resultPromise = axios.get(`git/${collection}/commits`, { auth });
22 | await assert.doesNotReject(resultPromise, 'The request should succeed');
23 | const res = await resultPromise;
24 | assert.strictEqual(res.status, 200);
25 | // console.log(res.data)
26 | assert(res.data.commits.length > 2);
27 | });
28 |
29 | it('Pull ' + testHASH + ' into staging collection', async function () {
30 | const resultPromise = axios.get(`git/${collection}?hash=${testHASH}`, { auth });
31 | await assert.doesNotReject(resultPromise, 'The request should succeed');
32 | const res = await resultPromise;
33 |
34 | assert.strictEqual(res.status, 200);
35 | assert.deepStrictEqual(res.data, {
36 | message: 'success',
37 | collection: `/db/apps/${collection}-stage`,
38 | hash: testHASH,
39 | });
40 | });
41 |
42 | it('Deploy staging to target collection', async function () {
43 | const resultPromise = axios.post(`git/${collection}`, {}, { auth });
44 | await assert.doesNotReject(resultPromise, 'The request should succeed');
45 | const res = await resultPromise;
46 | assert.strictEqual(res.status, 200);
47 | assert.strictEqual(res.data.message, 'success');
48 | });
49 |
50 | it('Check Hashes', async function () {
51 | const resultPromise = axios.get(`git/${collection}/hash`, { auth });
52 | await assert.doesNotReject(resultPromise, 'the request should succeed');
53 | const res = await resultPromise;
54 |
55 | assert.strictEqual(res.status, 200);
56 | assert.strictEqual(res.data['local-hash'], testHASH);
57 | });
58 |
59 | describe('Incremental update', async function () {
60 | let newFiles;
61 | let delFiles;
62 |
63 | describe('can do a dry run', async function () {
64 | let dryRunResponse
65 | before(async function () {
66 | const resultPromise = axios.post(
67 | `git/${collection}/incremental?dry=true`,
68 | {},
69 | { auth },
70 | );
71 | await assert.doesNotReject(resultPromise, 'The request should succeed');
72 | dryRunResponse = await resultPromise;
73 | })
74 |
75 | it('Succeeds', function () {
76 | assert.strictEqual(dryRunResponse.status, 200);
77 | assert.strictEqual(dryRunResponse.data.message, 'dry-run');
78 | });
79 |
80 | it('Returns a list of new resources', async function () {
81 | newFiles = await Promise.all(
82 | dryRunResponse.data.changes.new.map(async (resource) => {
83 | const resourceInfo = await getResourceInfo(
84 | `/db/apps/${collection}/${resource.path}`,
85 | );
86 | return [resource, resourceInfo.modified];
87 | }),
88 | );
89 | // console.log('files to fetch', newFiles)
90 |
91 | assert.strictEqual(newFiles.length, 3);
92 | assert.strictEqual(newFiles[0][0].path, 'data/F-aww.xml');
93 | assert(newFiles[0][1] instanceof Date);
94 |
95 | assert.deepStrictEqual(newFiles[1], [{ path: 'data/F-tit2.xml' }, undefined]);
96 |
97 | assert.strictEqual(newFiles[2][0].path, 'data/F-ham.xml');
98 | assert(newFiles[2][1] instanceof Date);
99 | });
100 |
101 | it('Returns a list of resources to be deleted', async function () {
102 | delFiles = dryRunResponse.data.changes.del;
103 |
104 | assert(delFiles.length > 0);
105 | assert.deepStrictEqual(delFiles, [
106 | { path: 'data/F-wiv.xml' },
107 | { path: 'data/F-tit.xml' },
108 | ]);
109 | });
110 | });
111 |
112 | describe('can do a run', async function () {
113 | let incrementalUpdateResponse;
114 | before(async function () {
115 | incrementalUpdateResponse = await axios.post(
116 | `git/${collection}/incremental`,
117 | {},
118 | { auth },
119 | );
120 | // console.log('incrementalUpdateResponse', incrementalUpdateResponse.data)
121 | });
122 |
123 | it('succeeds', function () {
124 | assert.strictEqual(incrementalUpdateResponse.status, 200);
125 | assert.strictEqual(incrementalUpdateResponse.data.message, 'success');
126 | });
127 |
128 | it('updates all changed resources', async function () {
129 | await Promise.all(
130 | newFiles.map(async (resource) => {
131 | const { modified } = await getResourceInfo(
132 | `/db/apps/${collection}/${resource[0].path}`,
133 | );
134 | assert.notStrictEqual(modified, undefined);
135 | assert.notStrictEqual(modified, resource[1]);
136 | }),
137 | );
138 | });
139 |
140 | it('deletes all deleted resources', async function () {
141 | await Promise.all(
142 | delFiles.map(async (resource) => {
143 | const resourceInfo = await getResourceInfo(
144 | `/db/apps/${collection}/${resource.path}`,
145 | );
146 | assert.deepStrictEqual(resourceInfo, {});
147 | }),
148 | );
149 | });
150 | });
151 | });
152 | });
153 |
--------------------------------------------------------------------------------
/src/modules/config.xqm:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 |
3 | module namespace config="http://e-editiones.org/tuttle/config";
4 |
5 | declare namespace repo="http://exist-db.org/xquery/repo";
6 |
7 | (:~
8 | : Configurtion file
9 | :)
10 | declare variable $config:tuttle-config as element(tuttle) := doc("/db/apps/tuttle/data/tuttle.xml")/tuttle;
11 |
12 | declare variable $config:default-ns := "http://e-editiones.org/tuttle/callbacks";
13 |
14 | (:~
15 | : Git configuration
16 | :)
17 | declare function config:collections($collection-name as xs:string) as map(*)? {
18 | let $collection-config := $config:tuttle-config/repos/collection[@name = $collection-name]
19 | let $path := config:prefix() || $collection-name
20 |
21 | return
22 | if (empty($collection-config))
23 | then (
24 | (: error((), "Collection config for '" || $collection || "' not found!") :)
25 | )
26 | else map {
27 | "repo" : $collection-config/repo/string(),
28 | "owner" : $collection-config/owner/string(),
29 | "project-id" : $collection-config/project-id/string(),
30 | "ref": $collection-config/ref/string(),
31 | "collection": $collection-name,
32 |
33 | "type": $collection-config/type/string(),
34 | "baseurl": $collection-config/baseurl/string(),
35 | "hookuser": $collection-config/hookuser/string(),
36 |
37 | "path": $path,
38 | "deployed": config:deployed-sha($path),
39 |
40 | (: be careful never to expose these :)
41 | "hookpasswd": $collection-config/hookpasswd/string(),
42 | "token": config:token($collection-config)
43 | }
44 | };
45 |
46 | (:~
47 | : Which commit is deployed?
48 | :)
49 | declare function config:deployed-sha($path as xs:string) as xs:string? {
50 | (: @TODO: shares a lot of code with app.xqm app:read-commit-info :)
51 | if (doc-available($path || "/repo.xml")) then (
52 | doc($path || "/repo.xml")//repo:meta/@commit-id
53 | ) else if (doc-available($path || "/gitsha.xml")) then (
54 | doc($path || "/gitsha.xml")/hash/value
55 | ) else ()
56 | };
57 |
58 | declare function config:get-callback($config as map(*)) as function(*)? {
59 | let $collection-config := $config:tuttle-config/repos/collection[@name = $config?collection]
60 | return
61 | if (empty($collection-config/callback)) then (
62 | ) else if (count($collection-config/callback) ne 1) then (
63 | error(
64 | xs:QName("config:multiple-callbacks"),
65 | "More than one callback is not allowed: " || $collection-config/@name,
66 | $collection-config/callback
67 | )
68 | ) else (
69 | let $ns :=
70 | if ($collection-config/callback/@ns)
71 | then $collection-config/callback/@ns/string()
72 | else $config:default-ns
73 |
74 | let $qname :=
75 | try {
76 | QName($ns, $collection-config/callback/@name/string())
77 | } catch * {
78 | error(
79 | xs:QName("config:callback-qname"),
80 | "Callback QName problem: " || $collection-config/@name,
81 | $collection-config/callback
82 | )
83 | }
84 |
85 | let $import-options :=
86 | map {
87 | "location-hints" :
88 | if (exists($collection-config/callback/@location)) then (
89 | $collection-config/callback/@location/string()
90 | ) else (
91 | "/db/apps/tuttle/content/callbacks.xqm"
92 | )
93 | }
94 |
95 | (: get function reference :)
96 | let $module :=
97 | try {
98 | fn:load-xquery-module($ns, $import-options)
99 | } catch * {
100 | error(
101 | xs:QName("config:callback-module-load"),
102 | "Problem loading the callback for collection " || $collection-config/@name,
103 | $collection-config/callback
104 | )
105 | }
106 |
107 | return
108 | if (
109 | (: callback must have arity 2
110 | : (map(*),map(*)) -> item()? :)
111 | map:contains($module?functions?($qname), 2)
112 | ) then (
113 | util:log("info", ('Found callback function ', $qname, '#', 2)),
114 | $module?functions?($qname)?2
115 | ) else (
116 | error(
117 | xs:QName("config:callback-not-found"),
118 | "Callback function could not be found in module: " || $collection-config/@name,
119 | $collection-config/callback
120 | )
121 | )
122 | )
123 | };
124 |
125 | declare %private function config:token($collection-config as element(collection)) as xs:string? {
126 | let $env-var := "tuttle_token_" || replace($collection-config/@name/string(), "-", "_")
127 | let $token-env := environment-variable($env-var)
128 |
129 | return
130 | if (exists($token-env) and $token-env ne "") then (
131 | $token-env
132 | ) else (
133 | $collection-config/token
134 | )
135 | };
136 |
137 | (:~
138 | : List collection names
139 | :)
140 | declare function config:collection-config-available($collection as xs:string) as xs:boolean {
141 | exists($config:tuttle-config/repos/collection[@name = $collection])
142 | };
143 |
144 | (:~
145 | : List collection names
146 | :)
147 | declare function config:list-collections() as xs:string* {
148 | $config:tuttle-config/repos/collection/@name/string()
149 | };
150 |
151 |
152 | (:~
153 | : get default collection
154 | :)
155 | declare function config:default-collection() as xs:string? {
156 | $config:tuttle-config/repos/collection[default="true"]/@name/string()
157 | };
158 |
159 | (:~
160 | : ignore - these files are not checkout from git and are ignored
161 | :)
162 | declare function config:ignore() as xs:string* {
163 | $config:tuttle-config/ignore/file/string()
164 | };
165 |
166 | (:~
167 | : Suffix of the checked out git statging collection
168 | :)
169 | declare function config:suffix() as xs:string {
170 | $config:tuttle-config/config/@suffix/string()
171 | };
172 |
173 | (:~
174 | : The running task is stored in the lockfile. It ensures that two tasks do not run at the same time.
175 | :)
176 | declare function config:lock() as xs:string {
177 | $config:tuttle-config/config/@lock/string()
178 | };
179 |
180 | (:~
181 | : Prefix for collections
182 | :)
183 | declare function config:prefix() as xs:string {
184 | $config:tuttle-config/config/@prefix/string()
185 | };
186 |
187 | (:~
188 | : The destination where the key for the webhook is stored.
189 | :)
190 | declare function config:apikeys() as xs:string* {
191 | $config:tuttle-config/config/@apikeys/string()
192 | };
193 |
194 | (:~
195 | : DB User and Permissions as fallback if "permissions" not set in repo.xml
196 | :)
197 | declare function config:sm() as map(*) {
198 | let $sm := $config:tuttle-config/config/sm
199 |
200 | return map {
201 | "user" : $sm/@user/string(),
202 | "group" : $sm/@group/string(),
203 | "mode" : $sm/@mode/string()
204 | }
205 | };
206 |
--------------------------------------------------------------------------------
/test/github.js:
--------------------------------------------------------------------------------
1 | import { axios, auth, getResourceInfo, ensureTuttleIsInstalled } from './util.js';
2 | import { before, describe, it } from 'node:test';
3 | import assert from 'node:assert';
4 |
5 | export default () =>
6 | describe('Github', async function () {
7 | before(async () => {
8 | await ensureTuttleIsInstalled();
9 | });
10 | const testHASH = '79789e5c4842afaaa63c733c3ed6babe37f70121';
11 | const collection = 'tuttle-sample-data';
12 |
13 | it('Remove lockfile', async function () {
14 | const resultPromise = axios.get('git/lockfile', { auth });
15 | await assert.doesNotReject(resultPromise, 'The request should succeed');
16 | const res = await resultPromise;
17 | assert.strictEqual(res.status, 200, res.statusText);
18 | });
19 |
20 | it('Get changelog', async function () {
21 | const resultPromise = axios.get('git/commits', { auth });
22 | await assert.doesNotReject(resultPromise, 'The request should succeed');
23 | const res = await resultPromise;
24 |
25 | assert.strictEqual(res.status, 200);
26 | assert(res.data.commits.length > 2, 'there should have been at least two commits');
27 | });
28 |
29 | it('Pull ' + testHASH + ' into staging collection', async function () {
30 | const resultPromise = axios.get(`git/?hash=${testHASH}`, { auth });
31 | await assert.doesNotReject(resultPromise, 'The request should succeed');
32 | const res = await resultPromise;
33 |
34 | assert.strictEqual(res.status, 200);
35 | assert.deepStrictEqual(res.data, {
36 | message: 'success',
37 | collection: `/db/apps/${collection}-stage`,
38 | hash: testHASH,
39 | });
40 | });
41 |
42 | it('Deploy staging to target collection', async function () {
43 | const resultPromise = axios.post('git/', {}, { auth });
44 | await assert.doesNotReject(resultPromise, 'The request should succeed');
45 | const res = await resultPromise;
46 | assert.strictEqual(res.status, 200);
47 | assert.strictEqual(res.data.message, 'success');
48 | });
49 |
50 | it('Check Hashes', async function () {
51 | const resultPromise = axios.get('git/hash', { auth });
52 | await assert.doesNotReject(resultPromise, 'The request should succeed');
53 | const res = await resultPromise;
54 |
55 | assert.strictEqual(res.status, 200);
56 | assert.strictEqual(res.data['local-hash'], testHASH);
57 | });
58 |
59 | describe('Incremental update', async function () {
60 | describe('can do a dry run', async function () {
61 | let resultPromise, dryRunResponse
62 |
63 | before(async function () {
64 | resultPromise = axios.post('git/incremental?dry=true', {}, { auth });
65 | await assert.doesNotReject(resultPromise, 'The request should succeed');
66 | dryRunResponse = await resultPromise;
67 | })
68 |
69 | it('Succeeds', function () {
70 | assert.strictEqual(dryRunResponse.status, 200);
71 | assert.strictEqual(dryRunResponse.data.message, 'dry-run');
72 | });
73 |
74 | it('Returns a list of new resources', async function () {
75 | const newFiles = await Promise.all(
76 | dryRunResponse.data.changes.new.map(async (resource) => {
77 | const resourceInfo = await getResourceInfo(
78 | `/db/apps/${collection}/${resource.path}`,
79 | );
80 | return [resource, resourceInfo.modified];
81 | }),
82 | );
83 | // console.log('files to fetch', newFiles)
84 |
85 | assert.strictEqual(newFiles.length, 3);
86 | assert.strictEqual(newFiles[0][0].path, 'data/F-aww.xml');
87 | assert(newFiles[0][1] instanceof Date);
88 |
89 | assert.deepStrictEqual(
90 | newFiles[1],
91 | [{ path: 'data/F-tit2.xml' }, undefined],
92 | 'File was added, so no modified timestamp is expected',
93 | );
94 |
95 | assert.strictEqual(newFiles[2][0].path, 'data/F-ham.xml');
96 | assert(newFiles[2][1] instanceof Date);
97 | });
98 |
99 | it('Returns a list of resources to be deleted', async function () {
100 | const delFiles = dryRunResponse.data.changes.del;
101 |
102 | assert(delFiles.length > 0);
103 | assert.deepStrictEqual(delFiles, [
104 | { path: 'data/F-wiv.xml' },
105 | { path: 'data/F-tit.xml' },
106 | ]);
107 | });
108 | });
109 |
110 | describe('can do a run', async function () {
111 | let resultPromise, incrementalUpdateResponse
112 |
113 | before(async function () {
114 | const resultPromise = axios.post('git/incremental', {}, { auth });
115 | await assert.doesNotReject(resultPromise, 'The request should succeed');
116 | incrementalUpdateResponse = await resultPromise;
117 | })
118 |
119 | it('succeeds', function () {
120 | assert.strictEqual(incrementalUpdateResponse.status, 200);
121 | assert.strictEqual(incrementalUpdateResponse.data.message, 'success');
122 | });
123 |
124 | it('updates all changed resources', async function () {
125 | const newFiles = await Promise.all(
126 | incrementalUpdateResponse.data.changes.new.map(async (resource) => {
127 | const resourceInfo = await getResourceInfo(
128 | `/db/apps/${collection}/${resource.path}`,
129 | );
130 | return [resource, resourceInfo.modified];
131 | }),
132 | );
133 |
134 | await Promise.all(
135 | newFiles.map(async (resource) => {
136 | const { modified } = await getResourceInfo(
137 | `/db/apps/${collection}/${resource[0].path}`,
138 | );
139 | assert.ok(modified);
140 | assert.notStrictEqual(modified, resource[1]);
141 | }),
142 | );
143 | });
144 |
145 | it('deletes all deleted resources', async function () {
146 | const delFiles = incrementalUpdateResponse.data.changes.del;
147 |
148 | await Promise.all(
149 | delFiles.map(async (resource) => {
150 | const resourceInfo = await getResourceInfo(
151 | `/db/apps/${collection}/${resource.path}`,
152 | );
153 | assert.deepStrictEqual(resourceInfo, {});
154 | }),
155 | );
156 | });
157 | });
158 | });
159 | });
160 |
--------------------------------------------------------------------------------
/src/resources/css/styles.css:
--------------------------------------------------------------------------------
1 | html {
2 | min-height: 100vh;
3 | }
4 |
5 | body {
6 | font-family:Verdana, sans-serif;
7 | background: linear-gradient(180deg, rgba(19, 83, 102, 1) 6%, rgb(92, 112, 164) 100%);
8 | color: white;
9 | }
10 |
11 | a {
12 | color:#3b3b3b;
13 | }
14 |
15 | .api {
16 | font-size: 1rem;
17 | vertical-align: super;
18 | background: rgba(255,255,255,0.5);
19 | padding: 0.3rem;
20 | border-radius: 50%;
21 | color:#555;
22 | }
23 |
24 | .wrapper, fx-fore, fx-switch{
25 | height: auto;
26 | }
27 |
28 |
29 | button{
30 | padding: 0.5rem;
31 | }
32 | button span{
33 | display:inline-block;
34 | }
35 | .docs{
36 | position: absolute;
37 | right: 0;
38 | top: 0;
39 | background: rgba(255,255,255,0.5);
40 | width: 130px;
41 | height: 36px;
42 | display: flex;
43 | align-items: center;
44 | color: black !important;
45 | justify-content: center;
46 | border-bottom-left-radius: 12px;
47 | border-top-right-radius: 5px;
48 | }
49 | .error{
50 | position:relative;
51 | }
52 | .message{
53 | display:none;
54 | }
55 | .error .message{
56 | display: flex;
57 | position: absolute;
58 | width: calc(100% - 1rem);
59 | background: rgba(255,0,0,0.8);
60 | height: 100%;
61 | z-index: 1;
62 | justify-content: center;
63 | align-items: center;
64 | color: white;
65 | font-size: 1.4rem;
66 | padding-left:1rem;
67 | }
68 |
69 | fx-case{
70 | position: relative;
71 | }
72 | #repos{
73 | /*margin-top:10rem;*/
74 | }
75 |
76 | fx-group{
77 | margin-bottom:2rem;
78 | height: 100%;
79 | background: rgba(255,255,255,0.3);
80 | }
81 |
82 | #repos > fx-group{
83 | max-width:800px;
84 | margin:0 auto;
85 | padding:1rem;
86 | position:relative;
87 | height:auto;
88 | border-radius:0.5rem;
89 |
90 | backdrop-filter: blur(5px);
91 | background-color: rgba(255,255,255, 0.1);
92 | box-shadow: rgba(0, 0, 0, 0.2) 2px 8px 8px;
93 | border: 2px rgba(255,255,255,0.4) solid;
94 | border-bottom: 1px rgba(40,40,40,0.35) solid;
95 | border-right: 1px rgba(40,40,40,0.35) solid;
96 |
97 | }
98 | .overlay{
99 | /*display:none;*/
100 | display:block;
101 | position: absolute;
102 | width: 100vw;
103 | height: 100vh;
104 | top: 0;
105 | left: 0;
106 | background:rgba(220,220,240,0.9);
107 | z-index:50;
108 | }
109 |
110 | .overlay.loaded{
111 | display:none;
112 | }
113 | .overlay.progress-wrapper{
114 | display:block;
115 | }
116 | .wrap-overlay{
117 | width:380px;
118 | position:absolute;
119 | top:35%;
120 | left:50%;
121 | transform:translate(-50%,-50%);
122 | height:200px;
123 | display:grid;
124 | grid-template-columns:repeat(3,1fr);
125 | align-items:center;
126 | justify-items:center;
127 | background:rgba(255,255,255,0.5);
128 | border-radius:0.5rem;
129 | padding:0 2rem;
130 | }
131 | .icon{
132 | width:100px;
133 | }
134 | .icon.exist{
135 | }
136 |
137 | .icon.git{
138 | }
139 |
140 |
141 | .progress-wrapper{
142 | position:absolute;
143 | width:100%;
144 | height:100%;
145 | top:0;
146 | left:0;
147 | background:rgba(200,200,200,0.8);
148 | z-index:1;
149 | }
150 | fx-repeatitem{
151 | padding:0.5rem;
152 | }
153 | fx-repeatitem > div{
154 | padding:1rem;
155 | border-radius:0.3rem;
156 | display:grid;
157 | grid-template-columns:60px auto 120px 120px;
158 | align-items:center;
159 | grid-gap:0.3rem;
160 | opacity:0.8;
161 | /*box-shadow: rgb(0 0 0 / 20%) 2px 8px 8px;*/
162 | border: 2px rgba(255,255,255,0.4) solid;
163 | border-bottom: 1px rgba(40,40,40,0.35) solid;
164 | border-right: 1px rgba(40,40,40,0.35) solid;
165 |
166 | }
167 | fx-switch{
168 | width: 100%;
169 | height: 100%;
170 | display:none;
171 | }
172 | fx-switch.loaded{
173 | display:block;
174 | animation:fade 0.6s;
175 | }
176 |
177 |
178 | .gitUrl{
179 | display:grid;
180 | grid-template-columns:120px auto;
181 |
182 | }
183 | .gitUrl input{
184 | font-size:1.4rem;
185 | border-radius:3px;
186 | }
187 |
188 |
189 | fx-control, fx-trigger{
190 | display: inline-block;
191 | margin:0.3rem 0;
192 | }
193 |
194 | fx-control{
195 | display:grid;
196 | grid-template-columns:30% auto;
197 | }
198 | fx-trigger button{
199 | width:100%;
200 | }
201 | h1,h2,h3{
202 | font-weight:300;
203 | }
204 | h1 a{
205 | text-decoration: none;
206 | }
207 | h1{
208 | text-align:center;
209 | margin-top:4rem;
210 | color:white;
211 | font-size:3.5rem;
212 | }
213 | h2{
214 | font-size:3rem;
215 | margin-top:0;
216 | }
217 | h3{
218 | font-size:2rem;
219 | margin-bottom:0;
220 | }
221 |
222 | h2,h3{
223 | color:white;
224 | }
225 | h2{
226 | margin-bottom:2rem;
227 | }
228 |
229 | input{
230 | height: 1.5rem;
231 | }
232 | label{
233 | width:100px;
234 | display: inline-block;
235 | }
236 | .login{
237 | width:320px;
238 | position: absolute;
239 | top:50%;
240 | left:50%;
241 | transform: translateX(-50%);
242 | padding: 2rem;
243 | border:thin solid white;
244 | color:white;
245 | }
246 | .login fx-trigger{
247 | display:block;
248 | }
249 | .repoName{
250 | font-size:1.2rem;
251 | }
252 | .small{
253 | font-size:1rem;
254 | font-weight:300;
255 | margin-left:0.3rem;
256 | white-space:nowrap;
257 | display:inline-block;
258 | }
259 | .new{
260 | background:var(--paper-amber-500);
261 | }
262 |
263 | .tuttle{
264 | display:inline-block;
265 | border-radius: 32px;
266 | width:64px;
267 | filter: drop-shadow(2px 4px 6px white) opacity(0.75) hue-rotate(150deg);
268 | transform:translateY(1rem) translateX(-0.5rem);
269 | }
270 | .type{
271 | width:42px;
272 | height:42px;
273 | background-position:center center;
274 |
275 | }
276 | .type.github{
277 | background:url('../images/github.svg');
278 | background-size:cover;
279 | }
280 | .type.gitlab{
281 | background:url('../images/gitlab.svg');
282 | background-size:cover;
283 | }
284 | .uptodate{
285 | background:var(--paper-green-500);
286 | }
287 | .behind{
288 | background:var(--paper-red-500);
289 | }
290 |
291 | .loader,
292 | .loader:after {
293 | border-radius: 50%;
294 | width: 2em;
295 | height: 2em;
296 | }
297 |
298 |
299 |
300 |
301 | .loader-1 {
302 | width : 24px;
303 | height: 24px;
304 | border: 3px solid white;
305 | border-bottom-color: var(--paper-amber-500);
306 | border-radius: 50%;
307 | display: inline-block;
308 | animation: rotation 1s linear infinite;
309 | background:lightgrey;
310 | }
311 |
312 | .loader-26 {
313 | position:absolute;
314 | width : 124px;
315 | height: 124px;
316 | display: inline-block;
317 | border-radius:50%;
318 | opacity:0.5;
319 | top:25%;
320 | left:50%;
321 | transform:translateX(-50%);
322 | background:white;
323 | z-index:2;
324 | }
325 | .loader-26::after, .loader-26::before{
326 | content: '';
327 | width : 124px;
328 | height: 124px;
329 | border-radius: 50%;
330 | background: lightgrey;
331 | position: absolute;
332 | left:0;
333 | top: 0;
334 | animation: animloader14 2s linear infinite;
335 | }
336 | .loader-26::after{
337 | animation-delay: 1s;
338 |
339 | }
340 |
341 |
342 | @keyframes rotation {
343 | 0% { transform: rotate(0deg) }
344 | 100% { transform: rotate(360deg) }
345 | }
346 |
347 |
348 | @keyframes animloader14 {
349 | 0% { transform: scale(0); opacity: 1;}
350 | 100% { transform: scale(1); opacity: 0;}
351 | }
352 |
353 | .github-corner{
354 | position:absolute;
355 | right:0;
356 | top:0;
357 | z-index:10;
358 | }
359 |
360 | .arrow{
361 | transform: rotate(270deg);
362 | margin-left:-35px;
363 | }
364 | .arrow span{
365 | display: block;
366 | width: 30px;
367 | height: 30px;
368 | border-bottom: 5px solid #F0563A;
369 | border-right: 5px solid #F0563A;
370 |
371 | transform: rotate(45deg);
372 | margin: -10px;
373 | animation: animate 2s infinite;
374 | }
375 | .arrow span:nth-child(2){
376 | animation-delay: -0.2s;
377 | }
378 | .arrow span:nth-child(3){
379 | animation-delay: -0.4s;
380 | }
381 | @keyframes animate {
382 | 0%{
383 | opacity: 0;
384 | transform: rotate(45deg) translate(-20px,-20px);
385 | }
386 | 50%{
387 | opacity: 1;
388 | }
389 | 75%{
390 | }
391 | 100%{
392 | opacity: 0;
393 | transform: rotate(45deg) translate(20px,20px);
394 | border-bottom: 5px solid #06A8FF;
395 | border-right: 5px solid #06A8FF;
396 | }
397 | }
398 | @keyframes fade {
399 | 0%{
400 | opacity: 0;
401 | }
402 | 100%{
403 | opacity: 1;
404 | }
405 | }
406 |
407 |
408 |
409 |
--------------------------------------------------------------------------------
/test/tuttle.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import {
3 | auth,
4 | axios,
5 | ensureTuttleIsInstalled,
6 | getResource,
7 | install,
8 | putResource,
9 | remove,
10 | } from './util.js';
11 | import { before, describe, it } from 'node:test';
12 | import { readFile } from 'node:fs/promises';
13 |
14 | import { DOMParser } from 'slimdom';
15 |
16 | export default () =>
17 | describe('Tuttle', async function () {
18 | before(async () => {
19 | await ensureTuttleIsInstalled();
20 | });
21 | const defaultCollection = 'tuttle-sample-data';
22 |
23 | describe('git/status', async function () {
24 | let res, repos, defaultRepo
25 | before(async () => {
26 | const resultPromise = axios.get('git/status', { auth });
27 | await assert.doesNotReject(resultPromise, 'The request should succeed');
28 | res = await resultPromise;
29 | repos = res.data.repos;
30 | defaultRepo = res.data.default;
31 | })
32 |
33 | it('returns status 200', async function () {
34 | assert.strictEqual(res.status, 200);
35 | });
36 |
37 | it('default repo', async function () {
38 | assert.ok(defaultRepo);
39 | assert.strictEqual(defaultRepo, defaultCollection);
40 | });
41 |
42 | it('lists repos', async function () {
43 | assert.ok(repos);
44 | assert(repos.length > 0);
45 | });
46 |
47 | it('github sample repo is up to date', async function () {
48 | assert.deepStrictEqual(repos[0], {
49 | baseurl: 'https://api.github.com/',
50 | collection: defaultCollection,
51 | deployed: '5006b2cd6552e2b09ba94d597cf89c100de3399e',
52 | hookuser: 'admin',
53 | message: 'remote found',
54 | owner: 'eeditiones',
55 | path: `/db/apps/${defaultCollection}`,
56 | 'project-id': null,
57 | ref: 'next',
58 | remote: '5006b2cd6552e2b09ba94d597cf89c100de3399e',
59 | repo: 'tuttle-sample-data',
60 | status: 'uptodate',
61 | url: 'https://github.com/eeditiones/tuttle-sample-data',
62 | type: 'github',
63 | });
64 | });
65 |
66 | it('gitlab sample repo is not authorized', async function () {
67 | assert.deepStrictEqual(repos[1], {
68 | baseurl: 'https://gitlab.com/api/v4/',
69 | collection: 'tuttle-sample-gitlab',
70 | deployed: 'd80c71f0ac63d355f1583cfe2777fe3dcde4d8bc',
71 | hookuser: 'admin',
72 | message: 'remote found',
73 | owner: 'line-o',
74 | path: '/db/apps/tuttle-sample-gitlab',
75 | 'project-id': '50872175',
76 | ref: 'main',
77 | remote: 'd80c71f0ac63d355f1583cfe2777fe3dcde4d8bc',
78 | repo: 'tuttle-sample-data',
79 | status: 'uptodate',
80 | type: 'gitlab',
81 | url: 'https://gitlab.com/line-o/tuttle-sample-data.git'
82 | });
83 | });
84 | });
85 |
86 | describe('git/lockfile', async function () {
87 | let res
88 | before(async () => {
89 | const resultPromise = axios.get('git/lockfile', { auth });
90 | await assert.doesNotReject(resultPromise, 'The request should succeed');
91 | res = await resultPromise;
92 | });
93 |
94 | it('returns status 200', async function () {
95 | assert.strictEqual(res.status, 200);
96 | });
97 |
98 | it('confirms no lockfile to be present', async function () {
99 | assert.strictEqual(
100 | res.data.message,
101 | `No lockfile for '${defaultCollection}' found.`,
102 | );
103 | });
104 | });
105 |
106 | describe(`git/${defaultCollection}/lockfile`, async function () {
107 | let res
108 | before(async () => {
109 | const resultPromise = axios.get(`git/${defaultCollection}/lockfile`, { auth });
110 | await assert.doesNotReject(resultPromise, 'The request should succeed');
111 | res = await resultPromise;
112 | })
113 |
114 | it('returns status 200', async function () {
115 | assert.strictEqual(res.status, 200);
116 | });
117 |
118 | it('confirms no lockfile to be present', async function () {
119 | assert.strictEqual(
120 | res.data.message,
121 | `No lockfile for '${defaultCollection}' found.`,
122 | );
123 | });
124 | });
125 |
126 | describe('git/status with different settings', async function () {
127 | let repos,res
128 | before(async () => {
129 | const buffer = await readFile('./test/fixtures/alt-tuttle.xml');
130 | await putResource(buffer, '/db/apps/tuttle/data/tuttle.xml');
131 | const buffer2 = await readFile('./test/fixtures/test.xqm');
132 | await putResource(buffer2, '/db/apps/tuttle/modules/test.xqm');
133 |
134 | const resultPromise = axios.get('git/status', { auth });
135 | await assert.doesNotReject(resultPromise, 'The request should succeed');
136 | res = await resultPromise;
137 | repos = res.data.repos;
138 | })
139 |
140 | it('returns status 200', async function () {
141 | assert.strictEqual(res.status, 200);
142 | });
143 |
144 | it('lists repos', async function () {
145 | assert.ok(repos);
146 | assert(repos.length > 0);
147 | });
148 |
149 | it('has no default repo', async function () {
150 | assert.strictEqual(res.data.default, null);
151 | });
152 |
153 | it('ref "nonexistent" cannot be found in github sample repo ', async function () {
154 | // const resultPromise = axios.get('git/status', { auth });
155 | // await assert.doesNotReject(resultPromise, 'The request should succeed');
156 | // const res = await resultPromise;
157 | // const repos = res.data.repos;
158 |
159 | assert.deepStrictEqual(repos[0], {
160 | baseurl: 'https://api.github.com/',
161 | collection: 'tuttle-sample-data',
162 | deployed: '5006b2cd6552e2b09ba94d597cf89c100de3399e',
163 | hookuser: 'admin',
164 | message: 'server connection failed: Not Found (404)',
165 | owner: 'eeditiones',
166 | path: '/db/apps/tuttle-sample-data',
167 | 'project-id': null,
168 | ref: 'nonexistent',
169 | repo: 'tuttle-sample-data',
170 | status: 'error',
171 | type: 'github',
172 | });
173 | });
174 | });
175 |
176 | it('can also write hashes to repo.xml', async () => {
177 | await remove();
178 | await install();
179 |
180 | // Set up tuttle with a repo where repo.xml is used to store the git sha info
181 | const buffer = await readFile('./test/fixtures/alt-repoxml-tuttle.xml');
182 | await putResource(buffer, '/db/apps/tuttle/data/tuttle.xml');
183 |
184 | const resultPromise = axios.get('git/status', { auth });
185 | await assert.doesNotReject(resultPromise);
186 |
187 | const stagingPromise = axios.get('git/tuttle-sample-data', { auth });
188 | await assert.doesNotReject(stagingPromise, 'The request should succeed');
189 |
190 | const deployPromise = axios.post('git/tuttle-sample-data', {}, { auth });
191 | await assert.doesNotReject(deployPromise, 'The request should succeed');
192 |
193 | const repoXML = await getResource('/db/apps/tuttle-sample-data/repo.xml');
194 |
195 | const repo = new DOMParser().parseFromString(repoXML.toString(), 'text/xml').documentElement;
196 | assert.ok(repo.getAttribute('commit-id'), 'The commit id should be set');
197 | assert.ok(repo.getAttribute('commit-time'), 'The commit time should be set');
198 | assert.ok(repo.getAttribute('commit-date'), 'The commit date should be set');
199 | });
200 |
201 | describe('large histories', async () => {
202 | before(async () => {
203 | await remove();
204 | await install();
205 | });
206 |
207 | await it('can upgrade over a few hundred commits', async () => {
208 | // Set up tuttle with a repo with a ton of commits that it can upgrade over
209 | const buffer = await readFile('./test/fixtures/alt-big-repo-tuttle.xml');
210 | await putResource(buffer, '/db/apps/tuttle/data/tuttle.xml');
211 |
212 | const OLD_HASH = '41188098f120b6e70d1b0c3bb704a422eba43dfa';
213 | const stageOldVersionPromise = axios.get(`git/tuttle-sample-data?hash=${OLD_HASH}`, { auth });
214 | await assert.doesNotReject(stageOldVersionPromise);
215 | const deployOldVersionPromise = axios.post('git/tuttle-sample-data', {}, { auth });
216 | await assert.doesNotReject(deployOldVersionPromise, 'The request should succeed');
217 |
218 | const beforeString = await getResource('/db/apps/tuttle-sample-data/data/regular-changing-document.xml');
219 |
220 | const before = new DOMParser().parseFromString(beforeString.toString(), 'text/xml').documentElement;
221 | assert.strictEqual(before.textContent, 'Initial version');
222 |
223 | console.log('deployed older version of the sample data on the long-history branch')
224 |
225 | const resultPromise = axios.get('git/status', { auth });
226 | await assert.doesNotReject(resultPromise);
227 |
228 | const incrementalPromise = axios.post('git/tuttle-sample-data/incremental', {}, { auth });
229 | await assert.doesNotReject(incrementalPromise, 'The incremental request should succeed');
230 |
231 | const afterString = await getResource('/db/apps/tuttle-sample-data/data/regular-changing-document.xml');
232 |
233 | const after = new DOMParser().parseFromString(afterString.toString(), 'text/xml').documentElement;
234 | assert.strictEqual(after.textContent, 'change for 200');
235 | });
236 | });
237 | });
238 |
--------------------------------------------------------------------------------
/src/resources/css/vars.css:
--------------------------------------------------------------------------------
1 | /* Material Design color palette for Google products */
2 | html {
3 | --google-red-100: #f4c7c3;
4 | --google-red-300: #e67c73;
5 | --google-red-500: #db4437;
6 | --google-red-700: #c53929;
7 |
8 | --google-blue-100: #c6dafc;
9 | --google-blue-300: #7baaf7;
10 | --google-blue-500: #4285f4;
11 | --google-blue-700: #3367d6;
12 |
13 | --google-green-100: #b7e1cd;
14 | --google-green-300: #57bb8a;
15 | --google-green-500: #0f9d58;
16 | --google-green-700: #0b8043;
17 |
18 | --google-yellow-100: #fce8b2;
19 | --google-yellow-300: #f7cb4d;
20 | --google-yellow-500: #f4b400;
21 | --google-yellow-700: #f09300;
22 |
23 | --google-grey-100: #f5f5f5;
24 | --google-grey-300: #e0e0e0;
25 | --google-grey-500: #9e9e9e;
26 | --google-grey-700: #616161;
27 |
28 | /* Material Design color palette from online spec document */
29 |
30 | --paper-red-50: #ffebee;
31 | --paper-red-100: #ffcdd2;
32 | --paper-red-200: #ef9a9a;
33 | --paper-red-300: #e57373;
34 | --paper-red-400: #ef5350;
35 | --paper-red-500: #f44336;
36 | --paper-red-600: #e53935;
37 | --paper-red-700: #d32f2f;
38 | --paper-red-800: #c62828;
39 | --paper-red-900: #b71c1c;
40 | --paper-red-a100: #ff8a80;
41 | --paper-red-a200: #ff5252;
42 | --paper-red-a400: #ff1744;
43 | --paper-red-a700: #d50000;
44 |
45 | --paper-pink-50: #fce4ec;
46 | --paper-pink-100: #f8bbd0;
47 | --paper-pink-200: #f48fb1;
48 | --paper-pink-300: #f06292;
49 | --paper-pink-400: #ec407a;
50 | --paper-pink-500: #e91e63;
51 | --paper-pink-600: #d81b60;
52 | --paper-pink-700: #c2185b;
53 | --paper-pink-800: #ad1457;
54 | --paper-pink-900: #880e4f;
55 | --paper-pink-a100: #ff80ab;
56 | --paper-pink-a200: #ff4081;
57 | --paper-pink-a400: #f50057;
58 | --paper-pink-a700: #c51162;
59 |
60 | --paper-purple-50: #f3e5f5;
61 | --paper-purple-100: #e1bee7;
62 | --paper-purple-200: #ce93d8;
63 | --paper-purple-300: #ba68c8;
64 | --paper-purple-400: #ab47bc;
65 | --paper-purple-500: #9c27b0;
66 | --paper-purple-600: #8e24aa;
67 | --paper-purple-700: #7b1fa2;
68 | --paper-purple-800: #6a1b9a;
69 | --paper-purple-900: #4a148c;
70 | --paper-purple-a100: #ea80fc;
71 | --paper-purple-a200: #e040fb;
72 | --paper-purple-a400: #d500f9;
73 | --paper-purple-a700: #aa00ff;
74 |
75 | --paper-deep-purple-50: #ede7f6;
76 | --paper-deep-purple-100: #d1c4e9;
77 | --paper-deep-purple-200: #b39ddb;
78 | --paper-deep-purple-300: #9575cd;
79 | --paper-deep-purple-400: #7e57c2;
80 | --paper-deep-purple-500: #673ab7;
81 | --paper-deep-purple-600: #5e35b1;
82 | --paper-deep-purple-700: #512da8;
83 | --paper-deep-purple-800: #4527a0;
84 | --paper-deep-purple-900: #311b92;
85 | --paper-deep-purple-a100: #b388ff;
86 | --paper-deep-purple-a200: #7c4dff;
87 | --paper-deep-purple-a400: #651fff;
88 | --paper-deep-purple-a700: #6200ea;
89 |
90 | --paper-indigo-50: #e8eaf6;
91 | --paper-indigo-100: #c5cae9;
92 | --paper-indigo-200: #9fa8da;
93 | --paper-indigo-300: #7986cb;
94 | --paper-indigo-400: #5c6bc0;
95 | --paper-indigo-500: #3f51b5;
96 | --paper-indigo-600: #3949ab;
97 | --paper-indigo-700: #303f9f;
98 | --paper-indigo-800: #283593;
99 | --paper-indigo-900: #1a237e;
100 | --paper-indigo-a100: #8c9eff;
101 | --paper-indigo-a200: #536dfe;
102 | --paper-indigo-a400: #3d5afe;
103 | --paper-indigo-a700: #304ffe;
104 |
105 | --paper-blue-50: #e3f2fd;
106 | --paper-blue-100: #bbdefb;
107 | --paper-blue-200: #90caf9;
108 | --paper-blue-300: #64b5f6;
109 | --paper-blue-400: #42a5f5;
110 | --paper-blue-500: #2196f3;
111 | --paper-blue-600: #1e88e5;
112 | --paper-blue-700: #1976d2;
113 | --paper-blue-800: #1565c0;
114 | --paper-blue-900: #0d47a1;
115 | --paper-blue-a100: #82b1ff;
116 | --paper-blue-a200: #448aff;
117 | --paper-blue-a400: #2979ff;
118 | --paper-blue-a700: #2962ff;
119 |
120 | --paper-light-blue-50: #e1f5fe;
121 | --paper-light-blue-100: #b3e5fc;
122 | --paper-light-blue-200: #81d4fa;
123 | --paper-light-blue-300: #4fc3f7;
124 | --paper-light-blue-400: #29b6f6;
125 | --paper-light-blue-500: #03a9f4;
126 | --paper-light-blue-600: #039be5;
127 | --paper-light-blue-700: #0288d1;
128 | --paper-light-blue-800: #0277bd;
129 | --paper-light-blue-900: #01579b;
130 | --paper-light-blue-a100: #80d8ff;
131 | --paper-light-blue-a200: #40c4ff;
132 | --paper-light-blue-a400: #00b0ff;
133 | --paper-light-blue-a700: #0091ea;
134 |
135 | --paper-cyan-50: #e0f7fa;
136 | --paper-cyan-100: #b2ebf2;
137 | --paper-cyan-200: #80deea;
138 | --paper-cyan-300: #4dd0e1;
139 | --paper-cyan-400: #26c6da;
140 | --paper-cyan-500: #00bcd4;
141 | --paper-cyan-600: #00acc1;
142 | --paper-cyan-700: #0097a7;
143 | --paper-cyan-800: #00838f;
144 | --paper-cyan-900: #006064;
145 | --paper-cyan-a100: #84ffff;
146 | --paper-cyan-a200: #18ffff;
147 | --paper-cyan-a400: #00e5ff;
148 | --paper-cyan-a700: #00b8d4;
149 |
150 | --paper-teal-50: #e0f2f1;
151 | --paper-teal-100: #b2dfdb;
152 | --paper-teal-200: #80cbc4;
153 | --paper-teal-300: #4db6ac;
154 | --paper-teal-400: #26a69a;
155 | --paper-teal-500: #009688;
156 | --paper-teal-600: #00897b;
157 | --paper-teal-700: #00796b;
158 | --paper-teal-800: #00695c;
159 | --paper-teal-900: #004d40;
160 | --paper-teal-a100: #a7ffeb;
161 | --paper-teal-a200: #64ffda;
162 | --paper-teal-a400: #1de9b6;
163 | --paper-teal-a700: #00bfa5;
164 |
165 | --paper-green-50: #e8f5e9;
166 | --paper-green-100: #c8e6c9;
167 | --paper-green-200: #a5d6a7;
168 | --paper-green-300: #81c784;
169 | --paper-green-400: #66bb6a;
170 | --paper-green-500: #4caf50;
171 | --paper-green-600: #43a047;
172 | --paper-green-700: #388e3c;
173 | --paper-green-800: #2e7d32;
174 | --paper-green-900: #1b5e20;
175 | --paper-green-a100: #b9f6ca;
176 | --paper-green-a200: #69f0ae;
177 | --paper-green-a400: #00e676;
178 | --paper-green-a700: #00c853;
179 |
180 | --paper-light-green-50: #f1f8e9;
181 | --paper-light-green-100: #dcedc8;
182 | --paper-light-green-200: #c5e1a5;
183 | --paper-light-green-300: #aed581;
184 | --paper-light-green-400: #9ccc65;
185 | --paper-light-green-500: #8bc34a;
186 | --paper-light-green-600: #7cb342;
187 | --paper-light-green-700: #689f38;
188 | --paper-light-green-800: #558b2f;
189 | --paper-light-green-900: #33691e;
190 | --paper-light-green-a100: #ccff90;
191 | --paper-light-green-a200: #b2ff59;
192 | --paper-light-green-a400: #76ff03;
193 | --paper-light-green-a700: #64dd17;
194 |
195 | --paper-lime-50: #f9fbe7;
196 | --paper-lime-100: #f0f4c3;
197 | --paper-lime-200: #e6ee9c;
198 | --paper-lime-300: #dce775;
199 | --paper-lime-400: #d4e157;
200 | --paper-lime-500: #cddc39;
201 | --paper-lime-600: #c0ca33;
202 | --paper-lime-700: #afb42b;
203 | --paper-lime-800: #9e9d24;
204 | --paper-lime-900: #827717;
205 | --paper-lime-a100: #f4ff81;
206 | --paper-lime-a200: #eeff41;
207 | --paper-lime-a400: #c6ff00;
208 | --paper-lime-a700: #aeea00;
209 |
210 | --paper-yellow-50: #fffde7;
211 | --paper-yellow-100: #fff9c4;
212 | --paper-yellow-200: #fff59d;
213 | --paper-yellow-300: #fff176;
214 | --paper-yellow-400: #ffee58;
215 | --paper-yellow-500: #ffeb3b;
216 | --paper-yellow-600: #fdd835;
217 | --paper-yellow-700: #fbc02d;
218 | --paper-yellow-800: #f9a825;
219 | --paper-yellow-900: #f57f17;
220 | --paper-yellow-a100: #ffff8d;
221 | --paper-yellow-a200: #ffff00;
222 | --paper-yellow-a400: #ffea00;
223 | --paper-yellow-a700: #ffd600;
224 |
225 | --paper-amber-50: #fff8e1;
226 | --paper-amber-100: #ffecb3;
227 | --paper-amber-200: #ffe082;
228 | --paper-amber-300: #ffd54f;
229 | --paper-amber-400: #ffca28;
230 | --paper-amber-500: #ffc107;
231 | --paper-amber-600: #ffb300;
232 | --paper-amber-700: #ffa000;
233 | --paper-amber-800: #ff8f00;
234 | --paper-amber-900: #ff6f00;
235 | --paper-amber-a100: #ffe57f;
236 | --paper-amber-a200: #ffd740;
237 | --paper-amber-a400: #ffc400;
238 | --paper-amber-a700: #ffab00;
239 |
240 | --paper-orange-50: #fff3e0;
241 | --paper-orange-100: #ffe0b2;
242 | --paper-orange-200: #ffcc80;
243 | --paper-orange-300: #ffb74d;
244 | --paper-orange-400: #ffa726;
245 | --paper-orange-500: #ff9800;
246 | --paper-orange-600: #fb8c00;
247 | --paper-orange-700: #f57c00;
248 | --paper-orange-800: #ef6c00;
249 | --paper-orange-900: #e65100;
250 | --paper-orange-a100: #ffd180;
251 | --paper-orange-a200: #ffab40;
252 | --paper-orange-a400: #ff9100;
253 | --paper-orange-a700: #ff6500;
254 |
255 | --paper-deep-orange-50: #fbe9e7;
256 | --paper-deep-orange-100: #ffccbc;
257 | --paper-deep-orange-200: #ffab91;
258 | --paper-deep-orange-300: #ff8a65;
259 | --paper-deep-orange-400: #ff7043;
260 | --paper-deep-orange-500: #ff5722;
261 | --paper-deep-orange-600: #f4511e;
262 | --paper-deep-orange-700: #e64a19;
263 | --paper-deep-orange-800: #d84315;
264 | --paper-deep-orange-900: #bf360c;
265 | --paper-deep-orange-a100: #ff9e80;
266 | --paper-deep-orange-a200: #ff6e40;
267 | --paper-deep-orange-a400: #ff3d00;
268 | --paper-deep-orange-a700: #dd2c00;
269 |
270 | --paper-brown-50: #efebe9;
271 | --paper-brown-100: #d7ccc8;
272 | --paper-brown-200: #bcaaa4;
273 | --paper-brown-300: #a1887f;
274 | --paper-brown-400: #8d6e63;
275 | --paper-brown-500: #795548;
276 | --paper-brown-600: #6d4c41;
277 | --paper-brown-700: #5d4037;
278 | --paper-brown-800: #4e342e;
279 | --paper-brown-900: #3e2723;
280 |
281 | --paper-grey-50: #fafafa;
282 | --paper-grey-100: #f5f5f5;
283 | --paper-grey-200: #eeeeee;
284 | --paper-grey-300: #e0e0e0;
285 | --paper-grey-400: #bdbdbd;
286 | --paper-grey-500: #9e9e9e;
287 | --paper-grey-600: #757575;
288 | --paper-grey-700: #616161;
289 | --paper-grey-800: #424242;
290 | --paper-grey-900: #212121;
291 |
292 | --paper-blue-grey-50: #eceff1;
293 | --paper-blue-grey-100: #cfd8dc;
294 | --paper-blue-grey-200: #b0bec5;
295 | --paper-blue-grey-300: #90a4ae;
296 | --paper-blue-grey-400: #78909c;
297 | --paper-blue-grey-500: #607d8b;
298 | --paper-blue-grey-600: #546e7a;
299 | --paper-blue-grey-700: #455a64;
300 | --paper-blue-grey-800: #37474f;
301 | --paper-blue-grey-900: #263238;
302 |
303 | /* opacity for dark text on a light background */
304 | --dark-divider-opacity: 0.12;
305 | --dark-disabled-opacity: 0.38; /* or hint text or icon */
306 | --dark-secondary-opacity: 0.54;
307 | --dark-primary-opacity: 0.87;
308 |
309 | /* opacity for light text on a dark background */
310 | --light-divider-opacity: 0.12;
311 | --light-disabled-opacity: 0.3; /* or hint text or icon */
312 | --light-secondary-opacity: 0.7;
313 | --light-primary-opacity: 1.0;
314 |
315 |
316 | /* model element styles for debugging */
317 | --model-element-padding:10px;
318 | --model-element-margin:10px;
319 | }
320 |
321 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Tuttle Dashboard
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 | Tuttle Git DashboardAPI
23 |
24 |
25 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {}
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | Login failed
68 |
69 |
70 |
76 |
77 |
78 |
79 | loaded
80 |
81 |
82 |
83 |
84 | loaded
85 |
86 |
87 |
88 |
89 |
94 |
95 |
96 | fetching data...
97 | progress-wrapper
98 |
99 |
100 | Data fetched
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
114 |
115 |
116 | Data deployed to database
117 | No expath-pkg.xml or repo.xml in repo
118 |
119 |
120 |
121 |
122 |
123 |
124 |
129 |
130 |
131 |
132 | progress-wrapper
133 |
134 |
135 |
136 | incremental update done
137 |
138 |
139 |
140 |
141 | Update Failed!
142 |
143 |
144 |
145 |
146 |
147 | return btoa(encodeURI($input));
148 |
149 |
150 |
151 |
152 |
153 |

154 |
155 |
156 |
157 |
158 |
159 |

160 |
161 |
162 |
163 |
164 |
179 |
180 |
181 |
182 |
183 | Documentation
184 | Git to DBimport data from Git into eXist-db
185 | Git Repositories
186 |
187 |
188 |
189 |
190 |
{@message}
191 |
{./@collection}
192 |
193 |
196 |
197 |
198 |
199 |
200 |
201 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
--------------------------------------------------------------------------------
/src/modules/gitlab.xqm:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 |
3 | module namespace gitlab="http://e-editiones.org/tuttle/gitlab";
4 |
5 | import module namespace app="http://e-editiones.org/tuttle/app" at "app.xqm";
6 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm";
7 |
8 | declare namespace http="http://expath.org/ns/http-client";
9 |
10 | declare function gitlab:repo-url($config as map(*)) as xs:string {
11 | ``[`{$config?baseurl}`projects/`{$config?project-id}`/repository]``
12 | };
13 |
14 | declare function gitlab:commit-by-ref-url($config as map(*), $ref as xs:string) as xs:string {
15 | gitlab:repo-url($config) || "/commits/" || $ref
16 | };
17 |
18 | (:
19 | the `commits` API endpoint is _paged_ and will only return _20_ commits by default
20 | ?ref_name={ref}&per_page=100
21 | will always return 100 commits, which might result in to much overhead
22 | :)
23 | declare function gitlab:commit-ref-url($config as map(*)) as xs:string {
24 | gitlab:repo-url($config) || "/commits/?ref_name=" || $config?ref
25 | };
26 |
27 | declare function gitlab:commit-ref-url($config as map(*), $per-page as xs:integer) as xs:string {
28 | gitlab:commit-ref-url($config) || "&per_page=" || $per-page
29 | };
30 |
31 | (:
32 | The Gitlab API allows to specify a revision range as the value for ref_name
33 | ?ref_name={ref}...{deployed_hash}
34 | This will only retrieve commits since deployed_hash but the result is paged as well.
35 |
36 | ?ref_name={ref}...{deployed_hash}&per_page=100
37 | will return _up to_ 100 commits until deployed_hash is reached.
38 | :)
39 | declare function gitlab:newer-commits-url($config as map(*), $base as xs:string, $per-page as xs:integer) as xs:string {
40 | gitlab:repo-url($config) || "/commits?ref_name=" || $config?ref || "..." || $base || "&per_page=" || $per-page
41 | };
42 |
43 | (:~
44 | : clone defines Version repo
45 | :)
46 | declare function gitlab:get-archive($config as map(*), $sha as xs:string) {
47 | gitlab:request(
48 | gitlab:repo-url($config) || "/archive.zip?sha=" || $sha, $config?token)
49 | };
50 |
51 | (:~
52 | : Get commit info for a specific sha
53 | :)
54 | declare function gitlab:get-specific-commit($config as map(*), $ref as xs:string) as map(*) {
55 | let $commit :=
56 | gitlab:request-json(
57 | gitlab:commit-by-ref-url($config, $ref), $config?token)
58 |
59 | return
60 | map {
61 | "sha" : $commit?id,
62 | "date": $commit?committed_date
63 | }
64 | };
65 |
66 | (:~
67 | : Get the last commit
68 | :)
69 | declare function gitlab:get-last-commit($config as map(*)) {
70 | let $commit :=
71 | array:head(
72 | gitlab:request-json(
73 | gitlab:commit-ref-url($config, 1), $config?token))
74 |
75 | return
76 | map {
77 | "sha" : $commit?id,
78 | "date": $commit?committed_date
79 | }
80 | };
81 |
82 | (:~
83 | : Get all commits
84 | :)
85 | declare function gitlab:get-commits($config as map(*)) as array(*)* {
86 | gitlab:get-commits($config, 100)
87 | };
88 |
89 | (:~
90 | : Get N commits
91 | :)
92 | declare function gitlab:get-commits($config as map(*), $count as xs:integer) as array(*)* {
93 | if ($count <= 0)
94 | then error(xs:QName("gitlab:illegal-argument"), "$count must be greater than zero in gitlab:get-commits")
95 | else
96 | let $json := gitlab:get-raw-commits($config, $count)
97 | let $commits :=
98 | if (empty($json))
99 | then []
100 | else if ($count >= array:size($json)) (: return everything :)
101 | then $json
102 | else array:subarray($json, 1, $count)
103 |
104 | return
105 | array:for-each($commits, gitlab:short-commit-info#1)
106 | };
107 |
108 | declare %private function gitlab:short-commit-info ($commit-info as map(*)) as array(*) {
109 | [
110 | $commit-info?id,
111 | $commit-info?message
112 | ]
113 | };
114 |
115 | (:~
116 | : Get commits in full
117 | :)
118 | declare function gitlab:get-raw-commits($config as map(*), $count as xs:integer) as array(*)? {
119 | gitlab:request-json(
120 | gitlab:commit-ref-url($config, $count), $config?token)
121 | };
122 |
123 | (:~
124 | : Get diff between production collection and gitlab-newest
125 | :)
126 | declare function gitlab:get-newest-commits($config as map(*)) {
127 | reverse(
128 | gitlab:request-json(
129 | gitlab:newer-commits-url($config, $config?deployed, 100), $config?token)
130 | ?*)
131 | };
132 |
133 | (:~
134 | : Check if sha exist
135 | :)
136 | declare function gitlab:available-sha($config as map(*), $sha as xs:string) as xs:boolean {
137 | $sha = gitlab:get-commits($config)?*?1
138 | };
139 |
140 | (:~
141 | : Get files removed and added from commit
142 | :)
143 | declare function gitlab:get-commit-files($config as map(*), $sha as xs:string) as array(*) {
144 | gitlab:request-json(
145 | gitlab:repo-url($config) || "/commits/" || $sha || "/diff?per_page=100", $config?token)
146 | };
147 |
148 | (:~
149 | : Get blob of a file
150 | :)
151 | declare function gitlab:get-blob($config as map(*), $filename as xs:string, $sha as xs:string) {
152 | let $file := escape-html-uri(replace($filename,"/", "%2f"))
153 | let $file-url := gitlab:repo-url($config) || "/files/" || $file || "?ref=" || $sha
154 | let $json := gitlab:request-json($file-url, $config?token)
155 |
156 | return
157 | util:base64-decode($json?content)
158 | };
159 |
160 | (:~
161 | : Get HTTP-URL
162 | :)
163 | declare function gitlab:get-url($config as map(*)) {
164 | let $info := gitlab:request-json($config?baseurl || "/projects/" || $config?project-id, $config?token)
165 |
166 | return $info?http_url_to_repo
167 | };
168 |
169 | (:~
170 | : Handle edge case where a file created in this changeset is also removed
171 | :
172 | : So, in order to not fire useless and potentially harmful side-effects like
173 | : triggers or indexing we filter out all of these documents as if they were
174 | : never there.
175 | :)
176 | declare function gitlab:remove-or-ignore ($changes as map(*), $filename as xs:string) as map(*) {
177 | if ($filename = $changes?new)
178 | then map:put($changes, "new", $changes?new[. ne $filename]) (: filter document from new :)
179 | else map:put($changes, "del", ($changes?del, $filename)) (: add document to be removed :)
180 | };
181 |
182 | declare %private function gitlab:aggregate-filechanges($changes as map(*), $next as map(*)) as map(*) {
183 | if ($next?renamed_file) then
184 | gitlab:remove-or-ignore($changes, $next?old_path)
185 | => map:put("new", ($changes?new, $next?new_path))
186 | else if ($next?deleted_file) then
187 | gitlab:remove-or-ignore($changes, $next?new_path)
188 | (: added or modified :)
189 | else if ($next?new_file or $next?new_path = $next?old_path) then
190 | map:put($changes, "new", ($changes?new, $next?new_path))
191 | else $changes
192 | };
193 |
194 | declare function gitlab:get-changes ($collection-config as map(*)) as map(*) {
195 | let $changes :=
196 | for $commit in gitlab:get-newest-commits($collection-config)
197 | return gitlab:get-commit-files($collection-config, $commit?short_id)?*
198 |
199 | let $aggregated := fold-left($changes, map{}, gitlab:aggregate-filechanges#2)
200 | let $filtered := fold-left($aggregated?new, map{}, app:ignore-reducer#2)
201 | return map {
202 | "del": $aggregated?del,
203 | "new": $filtered?new,
204 | "ignored": $filtered?ignored
205 | }
206 | };
207 |
208 | (:~
209 | : Run incremental update on collection in dry mode
210 | :)
211 | declare function gitlab:incremental-dry($config as map(*)) as map(*) {
212 | let $changes := gitlab:get-changes($config)
213 | return map {
214 | 'new': array{ $changes?new },
215 | 'del': array{ $changes?del },
216 | 'ignored': array{ $changes?ignored }
217 | }
218 | };
219 |
220 | (:~
221 | : Run incremental update on collection
222 | :)
223 | declare function gitlab:incremental($config as map(*)) as map(*) {
224 | let $last-commit := gitlab:get-last-commit($config)
225 | let $changes := gitlab:get-changes($config)
226 | let $new := gitlab:incremental-add($config, $changes?new, $last-commit?sha)
227 | let $del := gitlab:incremental-delete($config, $changes?del)
228 | let $writesha := app:write-commit-info($config?path, $last-commit)
229 | return map {
230 | 'new': array{ $new },
231 | 'del': array{ $del },
232 | 'ignored': array{ $changes?ignored }
233 | }
234 | };
235 |
236 | declare function gitlab:check-signature ($collection as xs:string, $apikey as xs:string) as xs:boolean {
237 | request:get-header("X-Gitlab-Token") = $apikey
238 | };
239 |
240 | (:~
241 | : Incremental updates delete files
242 | :)
243 | declare %private function gitlab:incremental-delete($config as map(*), $files as xs:string*) as array(*)* {
244 | for $filepath in $files
245 | return
246 | try {
247 | [ $filepath, app:delete-resource($config, $filepath) ]
248 | }
249 | catch * {
250 | [ $filepath, false(), map{
251 | "code": $err:code, "description": $err:description, "value": $err:value,
252 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
253 | }]
254 | }
255 | };
256 |
257 | (:~
258 | : Incremental update fetch and add files from git
259 | :)
260 | declare %private function gitlab:incremental-add($config as map(*), $files as xs:string*, $sha as xs:string) as array(*)* {
261 | for $filepath in $files
262 | return
263 | try {
264 | [ $filepath,
265 | app:add-resource($config, $filepath,
266 | gitlab:get-blob($config, $filepath, $sha))]
267 | }
268 | catch * {
269 | [ $filepath, false(), map{
270 | "code": $err:code, "description": $err:description, "value": $err:value,
271 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
272 | }]
273 | }
274 | };
275 |
276 | (:~
277 | : Gitlab request
278 | :)
279 |
280 | (: If the response header `x-next-page` has a value, there are commits missing. :)
281 | declare %private function gitlab:has-next-page($response as element(http:response)) as xs:boolean {
282 | let $x-next-page := $response//http:header[@name="x-next-page"]
283 | return exists($x-next-page/@value) and $x-next-page/@value/string() ne ''
284 | };
285 |
286 | declare %private function gitlab:request-json($url as xs:string, $token as xs:string?) {
287 | let $response := app:request-json(gitlab:build-request($url, $token))
288 |
289 | return (
290 | if (gitlab:has-next-page($response[1]))
291 | then util:log("warn", ('Paged gitlab request has next page! URL:', $url))
292 | else (),
293 | $response[2]
294 | )
295 | };
296 |
297 | declare %private function gitlab:request($url as xs:string, $token as xs:string?) {
298 | app:request(gitlab:build-request($url, $token))[2]
299 | };
300 |
301 | declare %private function gitlab:build-request($url as xs:string, $token as xs:string) as element(http:request) {
302 |
303 | {
304 | if (empty($token) or $token = "")
305 | then ()
306 | else
307 | }
308 |
309 | };
310 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Tuttle - a Git-integration for eXist-db
3 |
4 | Synchronizes your data collection with GitHub and GitLab.
5 |
6 | ## User Documentation
7 |
8 | [User Documentation](https://eeditiones.github.io/tuttle-doc/)
9 |
10 | ## Functionality
11 |
12 | * Sync data collection from Git to DB
13 | * Deal with multiple repositories
14 | * Incremental updates
15 | * Works with private or public repositories
16 | * Works with self hosted instances
17 | * Extendable to other git services
18 |
19 | ## Requirements
20 |
21 | - [node](https://nodejs.org/en/): `v22`
22 | - [exist-db](https://www.exist-db.org): `v5.5.1+ < 7.0.0`
23 |
24 | ## Installation
25 |
26 | Pre-built packages are available
27 | - as [github-releases](https://github.com/eeditiones/tuttle/releases)
28 | ```bash
29 | xst package install github-release tuttle --owner eeditiones
30 | ```
31 |
32 | - and on [exist-db's public package registry](https://exist-db.org/exist/apps/public-repo/packages/tuttle?eXist-db-min-version=5.5.1).
33 | ```bash
34 | xst package install from-registry tuttle
35 | ```
36 |
37 |
38 | ## Building from source
39 |
40 | Tuttle uses Gulp as its build tool which itself builds on NPM.
41 | To initialize the project and load dependencies run
42 |
43 | ```
44 | npm install
45 | ```
46 |
47 | | Run | Description |
48 | |---------|-------------|
49 | |```npm run build``` | builds the Tuttle package |
50 | |```npm run deploy``` | build and install Tuttle in one go |
51 |
52 | > Note: the `deploy` commands below assume that you have a local eXist-db running on port 8080. However the database connection can be configured (see gulp-exist documentation)
53 |
54 | ## Testing
55 |
56 | To run the local test suite you need
57 |
58 | * an instance of eXist running on `localhost:8080` and
59 | * `npm` to be available in your path
60 | * a GitHub personal access token with read access to public repositories
61 | * a gitlab personal access token with read access to public repositories
62 |
63 | In CI these access tokens are read from environment variables.
64 | You can do the same with
65 | ```bash
66 | export tuttle_token_tuttle_sample_data=; \
67 | export tuttle_token_gitlab_sample_data=; \
68 | path/to/startup.sh
69 | ```
70 |
71 | Alternatively, you can modify `/db/apps/tuttle/data/tuttle.xml` _and_ `test/fixtures/alt-tuttle.xml`, `test/fixtures/alt-repo-xml-tuttle.xml` to include your tokens. But remember to never commit them!
72 |
73 | Run tests with
74 |
75 | ```
76 | npm test
77 | ```
78 |
79 | ## Configuration
80 |
81 | Tuttle is configured in `data/tuttle.xml`.
82 |
83 | New with version 2.0.0:
84 |
85 | A commented example configuration is available `data/tuttle-example-config.xml`.
86 | If you want to update tuttle your modified configuration file will be backed up to
87 | `/db/tuttle-backup/tuttle.xml` and restored on installation of the new version.
88 |
89 | Otherwise, when no back up of an existing config-file is found, the example configuration is copied to `data/tuttle.xml`.
90 |
91 | > [!TIP]
92 | > When migrating from an earlier version you can copy your existing configuration to the backup location:
93 | > `xmldb:copy-resource('/db/apps/tuttle/data', 'tuttle.xml', '/db/tuttle-backup', 'tuttle.xml')`
94 |
95 | ### Repository configuration
96 |
97 | The repositories to keep in sync with a gitservice are all listed under the repos-element.
98 |
99 | The name-attribute refers to the **destination collection** also known as the **target collection**.
100 |
101 | #### Collection
102 |
103 | An example: ``
104 | The collection `/db/apps/tuttle-sample-data` is now considered to be kept in sync with a git repository.
105 |
106 | ```xml
107 |
108 | true
109 |
110 | github
111 | https://api.github.com/
112 |
113 | tuttle-sample-data
114 | tuttle-sample-data
115 |
116 | a-personal-access-token
117 |
118 | [a-branch]
119 |
120 | a-exist-user
121 | that-users-password
122 |
123 | ```
124 |
125 | #### type
126 |
127 | ```xml
128 | gitlab
129 | ```
130 |
131 | There are two supported git services at the moment `github` and `gitlab`
132 |
133 | #### baseurl
134 |
135 | ```xml
136 | https://api.server/
137 | ```
138 |
139 | * For github the baseurl is `https://api.github.com/` or your github-enterprise API endpoint
140 | * For gitlab the baseurl is `https://gitlab.com/api/v4/` but can also be your private gitlab server egg 'https://gitlab.existsolutions.com/api/v4/'
141 |
142 | #### repo, owner and project-id
143 |
144 | * For github you **have to** specify the owner and the repo
145 | * For gitlab you **have to** specify the project-id of the repository
146 |
147 |
148 | #### ref
149 |
150 | ```xml
151 | [main]
152 | ```
153 |
154 | Defines the branch you want to track.
155 |
156 | #### hookuser & hookpasswd
157 |
158 | #### token
159 |
160 | If a token is specified Tuttle authenticates against GitHub or GitLab. When a token is not defined, Tuttle assumes a public repository without any authentication.
161 |
162 | > [!NOTE]
163 | > Be aware of the rate limits for unauthenticated requests
164 | > GitHub allows 60 unauthenticated requests per hour but 5,000 for authenticated requests
165 |
166 | > [!TIP]
167 | > It is also possible to pass the token via an environment variable. The name of the variable have to be `tuttle_token_ + collection` (all dashes must be replaces by underscore). Example: `tuttle_token_tuttle_sample_data`
168 |
169 | ##### Create API-Keys for Github / Gitlab
170 |
171 | At this stage of development, the API keys must be generated via the API endpoint `/git/apikey` or for a specific collection `/git/{collection}/apikey`.
172 |
173 | In the configuration `tuttle.xml` the "hookuser" is used to define the dbuser which executes the update.
174 |
175 | Example configuration for GitHub:
176 | * 'Payload URL': https://existdb:8443/exist/apps/tuttle/git/hook
177 | * 'Content type': application/json
178 |
179 | Example configuration for GitLab:
180 | * 'URL' : https://46.23.86.66:8443/exist/apps/tuttle/git/hook
181 |
182 |
183 | ## Dashboard
184 |
185 | The dashboard lists all configured collections showing the health
186 | of all of them at a glance.
187 | Here, you can trigger a full deployment or an incremental update for each collection.
188 |
189 | Full deployment clones the repository from git at ref and installs it as a `.xar` file or just moves the staging collection.
190 | This is a way to get to a known state in case you encounter issues.
191 | An incremental update only applies those changes to the target collection that happened in the repository after the last synchronization.
192 |
193 | > [!NOTE]
194 | > Tuttle is built to keep track of **data collections**
195 |
196 | > [!NOTE]
197 | > Tuttle does not run pre- or post install scripts nor change the index configuration on incremental updates!
198 |
199 | ### Let's start
200 |
201 | 1) customize the configuration (`data/tuttle.xml`)
202 | 2) login to the dashboard
203 | 2) click on 'full' to trigger a full deployment from git to existdb
204 | 3) now you can update your collection with a click on 'incremental'
205 |
206 | Repositories from which a valid XAR (existing `expath-pkg.xml`) package can be generated are installed as a package, all others are created purely on the DB.
207 |
208 | > [!NOTE]
209 | > Note that there may be index problems if a collection is not installed as a package.
210 |
211 | ## API
212 |
213 | The page below is reachable via [api.html](api.html) in your installed tuttle app.
214 |
215 | 
216 |
217 | ### API endpoint description
218 |
219 | Calling the API without {collection} ``config:default-collection()`` is chosen.
220 |
221 | #### Fetch to staging collection
222 |
223 | `` GET ~/tuttle/{collection}/git``
224 |
225 | With this most basic endpoint the complete data repository is pulled from the gitservice.
226 | The data will not directly update the target collection but be stored in a staging
227 | collection.
228 |
229 | To update the target collection use another POST request to `/tuttle/git`.
230 |
231 | The data collection is stored in `/db/app/sample-collection-staging`.
232 |
233 | #### Deploy the collection
234 |
235 | `` POST ~/tuttle/{collection}/git``
236 |
237 | The staging collection `/db/app/sample-collection-staging` is deployed to `/db/app/sample-collection`. All permissions are set and a pre-install function is called if needed.
238 |
239 | #### Incremental update
240 |
241 | `` POST ~/tuttle/{collection}/git``
242 |
243 | All commits since the last update are applied.To ensure the integrity of the collection, all commits are deployed individually.
244 |
245 | #### Get the repository hashed
246 |
247 | `` GET ~/tuttle/{collection}/hash``
248 |
249 | Reports the GIT hashed of all participating collections and the hash of the remote repository.
250 |
251 | #### Get Commits
252 |
253 | `` GET ~/tuttle/{collection}/commits``
254 |
255 | Displays all commits with commit message of the repository.
256 |
257 | #### Hook Trigger
258 |
259 | `` GET ~/tuttle/{collection}/hook``
260 |
261 | The webhook is usually triggered by GitHub or GitLab.
262 | An incremental update is triggered.
263 | Authentication is done by APIKey. The APIKey must be set in the header of the request.
264 |
265 | #### Example für GitLab
266 | ``` curl --header 'X-Gitlab-Token: RajWFNCILBuQ8SWRfAAAJr7pHxo7WIF8Fe70SGV2Ah' http://127.0.0.1:8080/exist/apps/tuttle/git/hook```
267 |
268 | ### Generate the APIKey
269 |
270 | `` GET ~/tuttle/{collection}/apikey``
271 |
272 | The APIKey is generated and displayed once. If forgotten, it must be generated again.
273 |
274 |
275 | ### Display the Repository configuration and status
276 |
277 | `` GET ~/tuttle/config ``
278 |
279 | Displays the configuration and the state of the git repository.
280 |
281 | States:
282 | - uptodate: Collection is up to date with GIT
283 | - behind: Collection is behind GIT and need an update
284 | - new: Collection is not a tuttle collection, full deployment is needed
285 |
286 | ```xml
287 |
288 | sample-collection-github
289 |
290 |
291 |
292 |
293 |
294 | ```
295 |
296 | ### Remove Lockfile
297 |
298 | `` POST ~/tuttle/{collection}/lockfile ``
299 |
300 | Remove lockfile after anything goes wrong.
301 |
302 | #### Print Lockfile
303 |
304 | `` GET ~/tuttle/{collection}/lockfile ``
305 |
306 | The running task is stored in the lockfile. It ensures that two tasks do not run at the same time.
307 |
308 |
309 | ## Access token for gitservice (incomplete)
310 |
311 | To talk to the configured gitservice Tuttle needs an access token. These can
312 | be obtained from the respective service.
313 |
314 | * see [Creating a personal access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) for github
315 | * see [Personal access tokens](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) for Gitlab
316 |
317 | The key for the gitservice must be configured in Gitservice configuration as shown above.
318 |
319 | ## Roadmap
320 |
321 | - [ ] DB to Git
322 |
323 | ## Honorable mentions:
324 |
325 | 
326 |
327 | [Horace Parnell Tuttle - American astronomer](http://www.klima-luft.de/steinicke/ngcic/persons/tuttle.htm)
328 |
329 | [Archibald "Harry" Tuttle - Robert de Niro in Terry Gilliams' 'Brazil'](https://en.wikipedia.org/wiki/Brazil_(1985_film))
330 |
--------------------------------------------------------------------------------
/src/modules/app.xqm:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 |
3 | module namespace app="http://e-editiones.org/tuttle/app";
4 |
5 | import module namespace xmldb="http://exist-db.org/xquery/xmldb";
6 | import module namespace http="http://expath.org/ns/http-client";
7 | import module namespace compression="http://exist-db.org/xquery/compression";
8 | import module namespace sm="http://exist-db.org/xquery/securitymanager";
9 |
10 | import module namespace collection="http://existsolutions.com/modules/collection";
11 |
12 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm";
13 |
14 | declare namespace repo="http://exist-db.org/xquery/repo";
15 |
16 |
17 | declare function app:ignore-reducer($res, $next) {
18 | if ($next = ("build.xml") or starts-with($next, ".git"))
19 | then map:put($res, 'ignored', ($res?ignored, $next))
20 | else map:put($res, 'new', ($res?new, $next))
21 | };
22 |
23 | declare function app:extract-archive($zip as xs:base64Binary, $collection as xs:string) {
24 | compression:unzip($zip,
25 | app:unzip-filter#3, config:ignore(),
26 | app:unzip-store#4, $collection)
27 | };
28 |
29 | (:~
30 | : Unzip helper function
31 | :)
32 | declare function app:unzip-store($path as xs:string, $data-type as xs:string, $data as item()?, $base as xs:string) as map(*) {
33 | if ($data-type = 'folder') then (
34 | let $create := collection:create($base || "/" || substring-after($path, '/'))
35 | return map { "path": $path }
36 | ) else (
37 | try {
38 | let $resource := app:file-to-resource($base, substring-after($path, '/'))
39 | let $collection-check := collection:create($resource?collection)
40 | let $store := xmldb:store($resource?collection, $resource?name, $data)
41 | return map { "path": $path }
42 | }
43 | catch * {
44 | map { "path": $path, "error": $err:description }
45 | }
46 | )
47 | };
48 |
49 | (:~
50 | : Filter out ignored resources
51 | : returning true() _will_ extract the file or folder
52 | :)
53 | declare function app:unzip-filter($path as xs:string, $data-type as xs:string, $ignore as xs:string*) as xs:boolean {
54 | not(substring-after($path, '/') = $ignore)
55 | };
56 |
57 | (:~
58 | : Move staging collection to final collection
59 | :)
60 | declare function app:move-collection($collection-source as xs:string, $collection-target as xs:string) {
61 | xmldb:get-child-collections($collection-source)
62 | ! xmldb:move($collection-source || "/" || ., $collection-target),
63 | xmldb:get-child-resources($collection-source)
64 | ! xmldb:move($collection-source, $collection-target, .)
65 | };
66 |
67 | (:~
68 | : Cleanup destination collection - delete collections from target collection
69 | :)
70 | declare function app:cleanup-collection($collection as xs:string) {
71 | let $ignore := (config:ignore(), config:lock())
72 | return (
73 | xmldb:get-child-collections($collection)[not(.= $ignore)]
74 | ! xmldb:remove($collection || "/" || .),
75 | xmldb:get-child-resources($collection)[not(.= $ignore)]
76 | ! xmldb:remove($collection, .)
77 | )
78 | };
79 |
80 | (:~
81 | : Random apikey generator
82 | :)
83 | declare function app:random-key($length as xs:int) {
84 | let $secret :=
85 | for $loop in 1 to $length
86 | let $random1 := util:random(9)+48
87 | let $random2 := util:random(25)+65
88 | let $random3 := util:random(25)+97
89 | return
90 | if (util:random(2) = 1) then
91 | fn:codepoints-to-string(($random2))
92 | else if (util:random(2) = 1) then
93 | fn:codepoints-to-string(($random3))
94 | else
95 | fn:codepoints-to-string(($random1))
96 |
97 | return string-join($secret)
98 | };
99 |
100 | (:~
101 | : Write api key to config:apikeys()
102 | :)
103 | declare function app:write-apikey($collection as xs:string, $apikey as xs:string) {
104 | try {
105 | let $collection-prefix := tokenize(config:apikeys(), '[^/]+$')[1]
106 | let $apikey-resource := xmldb:encode(replace(config:apikeys(), $collection-prefix, ""))
107 | let $collection-check := collection:create($collection-prefix)
108 |
109 | return
110 | if (doc(config:apikeys())//apikeys/collection[name = $collection]/key/text()) then
111 | update replace doc(config:apikeys())//apikeys/collection[name = $collection]/key with {$apikey}
112 | else if (doc(config:apikeys())//apikeys) then
113 | let $add := {$collection}{$apikey}
114 | return update insert $add into doc(config:apikeys())//apikeys
115 | else
116 | let $add := {$collection}{$apikey}
117 | let $store := xmldb:store($collection-prefix, $apikey-resource, $add)
118 | let $chmod := sm:chmod(config:apikeys(), "rw-r-----")
119 | return $store
120 | }
121 | catch * {
122 | map {
123 | "_error": map {
124 | "code": $err:code, "description": $err:description, "value": $err:value,
125 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
126 | }
127 | }
128 | }
129 | };
130 |
131 | (:~
132 | : Write lock file
133 | :)
134 | declare function app:lock-write($collection as xs:string, $task as xs:string) {
135 | if (xmldb:collection-available($collection)) then (
136 | xmldb:store($collection, config:lock(),
137 | { $task })
138 | ) else ()
139 | };
140 |
141 | (:~
142 | : Delete lock file
143 | :)
144 | declare function app:lock-remove($collection as xs:string) {
145 | if (doc-available($collection || "/" || config:lock())) then (
146 | xmldb:remove($collection, config:lock())
147 | ) else ()
148 | };
149 |
150 | (:~
151 | : Set permissions to collection recursively
152 | :)
153 | declare function app:set-permission($collection as xs:string) {
154 | let $permissions := app:get-permissions($collection)
155 | let $callback := app:set-permission(?, ?, $permissions)
156 |
157 | return
158 | collection:scan($collection, $callback)
159 | };
160 |
161 | declare function app:get-permissions ($collection as xs:string) {
162 | if (
163 | doc-available($collection || "/repo.xml") and
164 | exists(doc($collection || "/repo.xml")//repo:permissions)
165 | ) then (
166 | let $repo := doc($collection || "/repo.xml")//repo:permissions
167 | return map {
168 | "user": $repo/@user/string(),
169 | "group": $repo/@group/string(),
170 | "mode": $repo/@mode/string()
171 | }
172 | ) else (
173 | config:sm()
174 | )
175 | };
176 |
177 | (:~
178 | : Set permissions for either a collection or resource
179 | :)
180 | declare function app:set-permission($collection as xs:string, $resource as xs:string?, $permissions as map(*)) {
181 | if (exists($resource)) then (
182 | xs:anyURI($resource) ! (
183 | sm:chown(., $permissions?user),
184 | sm:chgrp(., $permissions?group),
185 | sm:chmod(., $permissions?mode)
186 | )
187 | ) else (
188 | xs:anyURI($collection) ! (
189 | sm:chown(., $permissions?user),
190 | sm:chgrp(., $permissions?group),
191 | sm:chmod(., replace($permissions?mode, "(r.)-", "$1x"))
192 | )
193 | )
194 | };
195 |
196 | (:~
197 | : Write sha and commit time to repo.xml file.
198 | : Falls back to gitsha.xml for collections
199 | : that do not have a repo.xml
200 | :)
201 | declare function app:write-commit-info($collection as xs:string, $commit as map(*)) {
202 | if (doc-available($collection || '/repo.xml')) then (
203 | let $repoXML := doc($collection || '/repo.xml')//repo:meta
204 | let $updated :=
205 | {
206 | $repoXML/@* except ($repoXML/@commit-id, $repoXML/@commit-time, $repoXML/@commit-date),
207 | attribute commit-id { $commit?sha },
208 | attribute commit-time { app:iso-to-epoch($commit?date) },
209 | attribute commit-date { $commit?date },
210 | $repoXML/node()
211 | }
212 | return xmldb:store($collection, "repo.xml", $updated)
213 | ) else (
214 | (: No repo.xml, write gitsha.xml :)
215 | let $contents :=
216 |
217 | { $commit?sha }
218 | { $commit?date }
219 |
220 | return xmldb:store($collection, "gitsha.xml", $contents)
221 | )
222 | };
223 |
224 | declare function app:request-json($request as element(http:request)) as item()+ {
225 | let $raw := app:request($request)
226 | return (
227 | $raw[1],
228 | parse-json(util:base64-decode($raw[2]))
229 | )
230 | };
231 |
232 | (:~
233 | : Github request
234 | :)
235 | declare function app:request($request as element(http:request)) {
236 | (: let $_ := util:log("info", $request/@href) :)
237 | let $response := http:send-request($request)
238 | let $status-code := xs:integer($response[1]/@status)
239 |
240 | return
241 | if ($status-code >= 400) then (
242 | error(xs:QName("app:connection-error"), "server connection failed: " || $response[1]/@message || " (" || $status-code || ")", $response[1])
243 | ) else (
244 | $response
245 | )
246 | };
247 |
248 | (:~
249 | : Resolve relative file path against a base collection
250 | : app:file-to-resource("/db", "a/b/c") -> map { "name": "c", "collection": "/db/a/b/"}
251 | :
252 | : @param $base the absolute DB path to a collection; no slash at the end
253 | : @param $filepath never begins with slash and always points to a resource
254 | : @return a map with name and collection
255 | :)
256 | declare %private function app:file-to-resource($base as xs:string, $filepath as xs:string) as map(*) {
257 | let $parts := tokenize($filepath, '/')
258 | let $rel-path := subsequence($parts, 0, count($parts)) (: cut off last part :)
259 | return map {
260 | "name": xmldb:encode($parts[last()]),
261 | "collection": string-join(($base, $rel-path), "/") || "/"
262 | }
263 | };
264 |
265 | declare function app:delete-resource($config as map(*), $filepath as xs:string) as xs:boolean {
266 | let $resource := app:file-to-resource($config?path, $filepath)
267 | let $remove := xmldb:remove($resource?collection, $resource?name)
268 | let $remove-empty-col :=
269 | if (empty(xmldb:get-child-resources($resource?collection))) then (
270 | xmldb:remove($resource?collection)
271 | ) else ()
272 |
273 | return true()
274 | };
275 |
276 | (:~
277 | : Incremental update fetch and add files from git
278 | :)
279 | declare function app:add-resource($config as map(*), $filepath as xs:string, $data as item()) as xs:boolean {
280 | let $resource := app:file-to-resource($config?path, $filepath)
281 | let $permissions := app:get-permissions($config?path)
282 | let $collection-check :=
283 | if (xmldb:collection-available($resource?collection)) then (
284 | ) else (
285 | collection:create($resource?collection),
286 | app:set-permission($resource?collection, (), $permissions)
287 | )
288 |
289 | let $store := xmldb:store($resource?collection, $resource?name, $data)
290 | let $chmod := app:set-permission($resource?collection, $store, $permissions)
291 | return true()
292 | };
293 |
294 | declare function app:iso-to-epoch ($commit-time as xs:string) as xs:integer {
295 | (xs:dateTime($commit-time) - xs:dateTime('1970-01-01T00:00:00')) div xs:dayTimeDuration('PT1S')
296 | };
297 |
--------------------------------------------------------------------------------
/src/modules/github.xqm:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 |
3 | module namespace github="http://e-editiones.org/tuttle/github";
4 |
5 | import module namespace crypto="http://expath.org/ns/crypto";
6 |
7 | import module namespace app="http://e-editiones.org/tuttle/app" at "app.xqm";
8 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm";
9 |
10 | declare namespace http="http://expath.org/ns/http-client";
11 |
12 | declare variable $github:max-page-size := 100;
13 | declare variable $github:max-total-result-size := 500;
14 |
15 | declare function github:repo-url($config as map(*)) as xs:string {
16 | ``[`{$config?baseurl}`repos/`{$config?owner}`/`{$config?repo}`]``
17 | };
18 |
19 | declare function github:commit-by-ref-url($config as map(*), $ref as xs:string) as xs:string {
20 | github:repo-url($config) || "/commits/" || $ref
21 | };
22 |
23 | (:
24 | The `commits` API endpoint is _paged_ and will only return _30_ commits by default
25 | it is possible to add the parameter `&per_page=100` (100 being the maximum)
26 | But this will _always_ return 100 commits, which might result in to much overhead
27 | and still might not be enough.
28 |
29 | Pagination: https://docs.github.com/en/rest/guides/using-pagination-in-the-rest-api?apiVersion=2022-11-28
30 | :)
31 | declare function github:commit-ref-url($config as map(*)) as xs:string {
32 | github:repo-url($config) || "/commits?sha=" || $config?ref
33 | };
34 |
35 | declare function github:commit-ref-url($config as map(*), $per-page as xs:integer) as xs:string {
36 | github:commit-ref-url($config) || "&per_page=" || $per-page
37 | };
38 |
39 | (:~
40 | : Clone defines Version repo
41 | :)
42 | declare function github:get-archive($config as map(*), $sha as xs:string) as xs:base64Binary {
43 | github:download-file(
44 | github:repo-url($config) || "/zipball/" || $sha, $config?token)
45 | };
46 |
47 | (:~
48 | : Get commit info for a specific sha
49 | :)
50 | declare function github:get-specific-commit($config as map(*), $ref as xs:string) as map(*) {
51 | let $commit :=
52 | github:request-json-ignore-pages(
53 | github:commit-by-ref-url($config, $ref), $config?token)
54 |
55 | return map {
56 | "sha" : $commit?sha,
57 | "date": $commit?commit?committer?date
58 | }
59 | };
60 |
61 | (:~
62 | : Get the last commit
63 | :)
64 | declare function github:get-last-commit($config as map(*)) as map(*) {
65 | let $commit :=
66 | array:head(
67 | github:request-json-ignore-pages(
68 | github:commit-ref-url($config, 1), $config?token))
69 |
70 | return map {
71 | "sha" : $commit?sha,
72 | "date": $commit?commit?committer?date
73 | }
74 | };
75 |
76 | (:~
77 | : Get all commits
78 | :)
79 | declare function github:get-commits($config as map(*)) as array(*)* {
80 | github:get-commits($config, $github:max-page-size)
81 | };
82 |
83 | (:~
84 | : Get N commits
85 | :)
86 | declare function github:get-commits($config as map(*), $count as xs:integer) as array(*)* {
87 | if ($count <= 0)
88 | then error(xs:QName("github:illegal-argument"), "$count must be greater than zero in github:get-commits")
89 | else
90 | let $json := github:get-raw-commits($config, $count)
91 | let $commits :=
92 | if (empty($json))
93 | then []
94 | else if ($count >= array:size($json)) (: return everything :)
95 | then $json
96 | else array:subarray($json, 1, $count)
97 |
98 | return
99 | array:for-each($commits, github:short-commit-info#1)
100 | };
101 |
102 | declare %private function github:short-commit-info ($commit-info as map(*)) as array(*) {
103 | [
104 | $commit-info?sha,
105 | $commit-info?commit?message
106 | ]
107 | };
108 |
109 | (:~
110 | : Get commits in full
111 | :)
112 | declare function github:get-raw-commits($config as map(*), $count as xs:integer) as array(*)? {
113 | github:get-raw-commits($config, $count, ())
114 | };
115 |
116 | (:~
117 | : Get commits in full, going over pages until we find the commit with the correct hash
118 | :)
119 | declare function github:get-raw-commits (
120 | $config as map(*),
121 | $count as xs:integer,
122 | $stop-at-commit-id as xs:string?
123 | ) as array(*)? {
124 | let $stop-condition := if (empty($stop-at-commit-id)) then
125 | function ($_) {
126 | (: We are not looking for any SHA. Prevent going over all of the commits in a possibly big repo :)
127 | true()
128 | }
129 | else
130 | function ($results-on-page) {
131 | (: We can stop searching once we have all the commits between 'now' and the commit we looked for :)
132 | let $found-commits := $results-on-page?*?sha
133 | return $found-commits = $stop-at-commit-id
134 | }
135 |
136 | let $results := github:request-json-all-pages(
137 | github:commit-ref-url($config, $count),
138 | $config?token,
139 | $stop-condition
140 | )
141 | return array { $results?* }
142 | };
143 |
144 | (:~
145 | : Get diff between production collection and github-newest
146 | :)
147 | declare function github:get-newest-commits($config as map(*)) as xs:string* {
148 | let $deployed := $config?deployed
149 | let $commits := github:get-raw-commits($config, $github:max-page-size, $deployed)
150 | let $sha := $commits?*?sha
151 | let $how-many := index-of($sha, $deployed) - 1
152 | return
153 | if (empty($how-many)) then (
154 | error(
155 | xs:QName("github:commit-not-found"),
156 | 'The deployed commit hash ' || $deployed || ' was not found in the list of commits on the remote. Tuttle can only process incremental upgrades of ' || $github:max-total-result-size || '.')
157 | ) else (
158 | reverse(subsequence($sha, 1, $how-many))
159 | )
160 | };
161 |
162 | (:~
163 | : Check if sha exist
164 | : TODO: github API might offer a better way to check not only if the commit exists
165 | : but also if this commit is part of `ref`
166 | :)
167 | declare function github:available-sha($config as map(*), $sha as xs:string) as xs:boolean {
168 | $sha = github:get-commits($config)?*?1
169 | };
170 |
171 | declare function github:get-changes ($collection-config as map(*)) as map(*) {
172 | let $changes :=
173 | for $sha in github:get-newest-commits($collection-config)
174 | return github:get-commit-files($collection-config, $sha)?*
175 |
176 | (: aggregate file changes :)
177 | let $aggregated := fold-left($changes, map{}, github:aggregate-filechanges#2)
178 | let $filtered := fold-left($aggregated?new, map{}, app:ignore-reducer#2)
179 | return map {
180 | "del": $aggregated?del,
181 | "new": $filtered?new,
182 | "ignored": $filtered?ignored
183 | }
184 | };
185 |
186 | (:~
187 | : Handle edge case where a file created in this changeset is also removed
188 | :
189 | : So, in order to not fire useless and potentially harmful side-effects like
190 | : triggers or indexing we filter out all of these documents as if they were
191 | : never there.
192 | :)
193 | declare function github:aggregate-filechanges ($changes as map(*), $next as map(*)) as map(*) {
194 | switch ($next?status)
195 | case "added" return
196 | let $new := map:put($changes, "new", ($changes?new, $next?filename))
197 | (: if same file was re-added then remove from it "del" list :)
198 | return map:put($new, "del", $changes?del[. ne $next?filename])
199 | case "modified" return
200 | (: add to "new" list, make sure each entry is in there only once :)
201 | map:put($changes, "new", ($changes?new[. ne $next?filename], $next?filename))
202 | case "renamed" return
203 | let $new := map:put($changes, "new", ($changes?new, $next?filename))
204 | (: account for files that existed, were removed in one commit and then reinstated by renaming a file :)
205 | return map:put($new, "del", ($changes?del[. ne $next?filename], $next?previous_filename))
206 | case "removed" return
207 | (: ignore this document, if it was added _and_ removed in the same changeset :)
208 | if ($next?filename = $changes?new)
209 | then map:put($changes, "new", $changes?new[. ne $next?filename])
210 | (: guard against duplicates in deletions :)
211 | else map:put($changes, "del", ($changes?del[. ne $next?filename], $next?filename))
212 | default return
213 | (: unhandled cases: "copied", "changed", "unchanged" :)
214 | $changes
215 | };
216 |
217 | (:~
218 | : Run incremental update on collection in dry mode
219 | :)
220 | declare function github:incremental-dry($config as map(*)) {
221 | let $changes := github:get-changes($config)
222 | return map {
223 | 'new': array{ $changes?new },
224 | 'del': array{ $changes?del },
225 | 'ignored': array{ $changes?ignored }
226 | }
227 | };
228 |
229 | (:~
230 | : Run incremental update on collection
231 | :)
232 | declare function github:incremental($config as map(*)) {
233 | let $last-commit := github:get-last-commit($config)
234 | let $changes := github:get-changes($config)
235 | let $del := github:incremental-delete($config, $changes?del)
236 | let $new := github:incremental-add($config, $changes?new, $last-commit?sha)
237 | let $writesha := app:write-commit-info($config?path, $last-commit)
238 | return map {
239 | 'new': array{ $new },
240 | 'del': array{ $del },
241 | 'ignored': array{ $changes?ignored }
242 | }
243 | };
244 |
245 |
246 | (:~
247 | : Get files removed and added from commit
248 | :)
249 | declare function github:get-commit-files($config as map(*), $sha as xs:string) as array(*) {
250 | let $url := github:repo-url($config) || "/commits/" || $sha
251 | let $commit := github:request-json-all-pages($url, $config?token)
252 |
253 | return array { $commit?files?* }
254 | };
255 |
256 | (: TODO: make raw url configurable :)
257 | declare variable $github:raw-usercontent-endpoint := "https://raw.githubusercontent.com";
258 |
259 | (:~
260 | : Get blob of a file
261 | : https://raw.githubusercontent.com////
262 | :)
263 | declare %private function github:get-blob($config as map(*), $filename as xs:string, $sha as xs:string) {
264 | if (not(starts-with($config?baseurl, "https://api.github.com"))) then (
265 | (: for GitHub enterprise we have to query for the download url, this might return the contents directly :)
266 | let $blob-url := github:repo-url($config) || "/contents/" || escape-html-uri($filename) || "?ref=" || $sha
267 | let $json := github:request-json($blob-url, $config?token)
268 | let $content := $json?content
269 |
270 | return
271 | if ($json?content = "") (: endpoint did not return base64 encoded contents :)
272 | then github:download-file($json?download_url, $config?token)
273 | else util:base64-decode($content)
274 | ) else (
275 | (: for github.com we can construct the download url :)
276 | let $blob-url := string-join((
277 | $github:raw-usercontent-endpoint,
278 | $config?owner,
279 | $config?repo,
280 | $sha,
281 | escape-html-uri($filename)
282 | ), "/")
283 | return github:download-file($blob-url, $config?token)
284 | )
285 | };
286 |
287 | (:~
288 | : Get HTTP-URL
289 | :)
290 | declare function github:get-url($config as map(*)) {
291 | let $repo-info := github:request-json(github:repo-url($config), $config?token)
292 | return $repo-info?html_url
293 | };
294 |
295 | (:~
296 | : Check signature for Webhook
297 | :)
298 | declare function github:check-signature($collection as xs:string, $apikey as xs:string) as xs:boolean {
299 | let $signature := request:get-header("X-Hub-Signature-256")
300 | let $payload := util:binary-to-string(request:get-data())
301 | let $private-key := doc(config:apikeys())//apikeys/collection[name = $collection]/key/string()
302 | let $expected-signature := "sha256=" || crypto:hmac($payload, $private-key, "HmacSha256", "hex")
303 |
304 | return $signature = $expected-signature
305 | };
306 |
307 | (:~
308 | : Incremental updates delete files
309 | :)
310 | declare %private function github:incremental-delete($config as map(*), $files as xs:string*) as array(*)* {
311 | for $filepath in $files
312 | return
313 | try {
314 | [ $filepath, app:delete-resource($config, $filepath) ]
315 | }
316 | catch * {
317 | if (contains($err:description, "not found")) then (
318 | [ $filepath, true()]
319 | ) else (
320 | [ $filepath, false(), map{
321 | "code": $err:code, "description": $err:description, "value": $err:value,
322 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
323 | }]
324 | )
325 | }
326 | };
327 |
328 | (:~
329 | : Incremental update fetch and add files from git
330 | :)
331 | declare %private function github:incremental-add($config as map(*), $files as xs:string*, $sha as xs:string) as array(*)* {
332 | for $filepath in $files
333 | return
334 | try {
335 | [ $filepath,
336 | app:add-resource($config, $filepath,
337 | github:get-blob($config, $filepath, $sha))]
338 | }
339 | catch * {
340 | [ $filepath, false(), map{
341 | "code": $err:code, "description": $err:description, "value": $err:value,
342 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
343 | }]
344 | }
345 | };
346 |
347 | (:~
348 | : Github request
349 | :)
350 |
351 | (:~
352 | : If the response header `link` contains rel="next", there are commits missing.
353 |
356 | :)
357 | declare %private function github:has-next-page($response as element(http:response)) {
358 | exists($response/http:header[@name="link"])
359 | };
360 |
361 | declare %private function github:parse-link-header($link-header as xs:string) as map(*) {
362 | map:merge(
363 | tokenize($link-header, ', ')
364 | ! array { tokenize(., '; ') }
365 | ! map {
366 | replace(?2, "rel=""(.*?)""", "$1") : substring(?1, 2, string-length(?1) - 2)
367 | }
368 | )
369 | };
370 |
371 | declare variable $github:accept-header := ;
372 |
373 | (: api calls :)
374 | declare %private function github:request-json($url as xs:string, $token as xs:string?) {
375 | let $response :=
376 | app:request-json(
377 | github:build-request($url, (
378 | $github:accept-header,
379 | github:auth-header($token))
380 | ))
381 |
382 | return (
383 | if (github:has-next-page($response[1])) then (
384 | error(
385 | xs:QName("github:next-page"),
386 | 'Paged github request has next page! URL:' || $url,
387 | github:parse-link-header($response[1]/http:header[@name="link"]/@value)?next
388 | )
389 | ) else (),
390 | $response[2]
391 | )
392 | };
393 |
394 | (:~
395 | : Get all pages of a specified URL. Github has some paginated endpoints, This function traverses all of
396 | : those and joins the results.
397 | :)
398 | declare %private function github:request-json-all-pages($url as xs:string, $token as xs:string?) {
399 | github:request-json-all-pages($url, $token, function ($_) { (: Traverse all pages :) false() }, ())
400 | };
401 |
402 | (:~
403 | : Overload, adds the $stop-condition callback which is given the contents of the current page
404 | : return `true()` to indicate there are sufficient results and we can stop
405 | :)
406 | declare %private function github:request-json-all-pages(
407 | $url as xs:string,
408 | $token as xs:string?,
409 | $stop-condition as function(map(*)) as xs:boolean
410 | ) {
411 | github:request-json-all-pages($url, $token, $stop-condition, ())
412 | };
413 |
414 | declare %private function github:request-json-all-pages(
415 | $url as xs:string,
416 | $token as xs:string?,
417 | $stop-condition as function(map(*)) as xs:boolean,
418 | $acc
419 | ) {
420 | let $response :=
421 | app:request-json(
422 | github:build-request($url, (
423 | $github:accept-header,
424 | github:auth-header($token))
425 | ))
426 |
427 | let $next-url :=
428 | if (github:has-next-page($response[1])) then (
429 | github:parse-link-header($response[1]/http:header[@name="link"]/@value)?next
430 | ) else ()
431 |
432 | let $results-in-this-page := $response[2]
433 | let $all := ($acc, $results-in-this-page)
434 |
435 | let $should-stop := $stop-condition($results-in-this-page)
436 |
437 | return (
438 | if (not($should-stop) and count($all?*) < $github:max-total-result-size and exists($next-url)) then (
439 | github:request-json-all-pages($next-url, $token, $stop-condition, $all)
440 | ) else (
441 | $all
442 | )
443 | )
444 | };
445 |
446 | (:~
447 | : api calls where it is clear that more pages will be returned but we do not need them
448 | : for instance when the limit is set to 1 result per page when we only need the head commit
449 | :)
450 | declare %private function github:request-json-ignore-pages($url as xs:string, $token as xs:string?) {
451 | app:request-json(
452 | github:build-request($url, (
453 | $github:accept-header,
454 | github:auth-header($token))
455 | ))[2]
456 | };
457 |
458 | (: raw file downloads :)
459 | declare %private function github:download-file ($url as xs:string, $token as xs:string?) {
460 | app:request(
461 | github:build-request($url,
462 | github:auth-header($token)))[2]
463 | };
464 |
465 | declare %private function github:auth-header($token as xs:string?) as element(http:header)? {
466 | if (empty($token) or $token = "")
467 | then ()
468 | else
469 | };
470 |
471 | declare %private function github:build-request($url as xs:string, $headers as element(http:header)*) as element(http:request) {
472 | { $headers }
473 | };
474 |
--------------------------------------------------------------------------------
/src/modules/api.xq:
--------------------------------------------------------------------------------
1 | xquery version "3.1";
2 |
3 | declare namespace api="http://exist-db.org/apps/tuttle/api";
4 | declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization";
5 |
6 | import module namespace roaster="http://e-editiones.org/roaster";
7 | import module namespace xmldb="http://exist-db.org/xquery/xmldb";
8 | import module namespace compression="http://exist-db.org/xquery/compression";
9 |
10 | import module namespace vcs="http://e-editiones.org/tuttle/vcs" at "vcs.xqm";
11 | import module namespace app="http://e-editiones.org/tuttle/app" at "app.xqm";
12 | import module namespace config="http://e-editiones.org/tuttle/config" at "config.xqm";
13 | import module namespace collection="http://existsolutions.com/modules/collection";
14 |
15 |
16 | (:~
17 | : list of definition files to use
18 | :)
19 | declare variable $api:definitions := ("api.json");
20 |
21 | (:~
22 | : Post git status
23 | :)
24 | declare function api:get-status($request as map(*)) {
25 | if (contains(request:get-header('Accept'), 'application/json'))
26 | then
27 | map {
28 | 'default': config:default-collection(),
29 | 'repos': array {
30 | for $collection-info in config:list-collections()
31 | return
32 | api:collection-info($collection-info)
33 | }
34 | }
35 | else
36 |
37 | {config:default-collection()}
38 | {
39 | for $collection-info in config:list-collections()
40 | return
41 | api:repo-xml(
42 | api:collection-info($collection-info))
43 | }
44 |
45 | };
46 |
47 | declare function api:repo-xml ($info as map(*)) as element(repo) {
48 | element repo {
49 | map:for-each($info, function ($name as xs:string, $value) as attribute() {
50 | attribute { $name } { $value }
51 | })
52 | }
53 | };
54 |
55 | declare function api:collection-info ($collection as xs:string) as map(*) {
56 | let $collection-config := api:get-collection-config($collection)
57 | (: hide passwords and tokens :)
58 | let $masked :=
59 | map:remove(
60 | map:remove($collection-config, "hookpasswd"), "token")
61 |
62 | return
63 | try {
64 | let $actions := vcs:get-actions($collection-config?type)
65 | let $url := $actions?get-url($collection-config)
66 | let $last-remote-commit := $actions?get-last-commit($collection-config)
67 | let $remote-sha := $last-remote-commit?sha
68 |
69 | let $status :=
70 | if ($remote-sha = "")
71 | then "error"
72 | else if (empty($collection-config?deployed))
73 | then "new"
74 | else if ($collection-config?deployed = $remote-sha)
75 | then "uptodate"
76 | else "behind"
77 |
78 | let $message :=
79 | if ($status = "error" )
80 | then "no commit on remote"
81 | else "remote found"
82 |
83 | return map:merge(( $masked, map {
84 | 'url': $url,
85 | 'remote': $remote-sha,
86 | 'message': $message,
87 | 'status': $status
88 | }))
89 | }
90 | catch * {
91 | map:merge(( $masked, map {
92 | 'message': $err:description,
93 | 'status': 'error'
94 | }))
95 | }
96 | };
97 |
98 | (:~
99 | : Post current hash and remote hash
100 | :)
101 | declare function api:get-hash($request as map(*)) as map(*) {
102 | try {
103 | let $collection-config := api:get-collection-config($request?parameters?collection)
104 | let $actions := vcs:get-actions($collection-config?type)
105 | let $collection-staging := $collection-config?path || config:suffix() || "/gitsha.xml"
106 |
107 | let $last-remote-commit := $actions?get-last-commit($collection-config)
108 |
109 | return map {
110 | "remote-hash": $last-remote-commit?sha,
111 | "local-hash": $collection-config?deployed,
112 | "local-staging-hash": doc($collection-staging)/hash/value/string()
113 | }
114 | }
115 | catch * {
116 | api:catch-error("api:get-hash", map {
117 | "code": $err:code, "description": $err:description, "value": $err:value,
118 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
119 | })
120 | }
121 | };
122 |
123 | (:~
124 | : Remove lockfile
125 | :)
126 | declare function api:lock-remove($request as map(*)) as map(*) {
127 | try {
128 | let $config := api:get-collection-config($request?parameters?collection)
129 | let $lockfile := $config?path || "/" || config:lock()
130 |
131 | let $message :=
132 | if (not(doc-available($lockfile)))
133 | then "Lockfile " || $lockfile || " does not exist"
134 | else
135 | let $remove := xmldb:remove($config?path, config:lock())
136 | return "Removed lockfile: " || $lockfile
137 |
138 | return map { "message": $message }
139 | }
140 | catch * {
141 | api:catch-error("api:lock-remove", map {
142 | "code": $err:code, "description": $err:description, "value": $err:value,
143 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
144 | })
145 | }
146 | };
147 |
148 | (:~
149 | : Print lockfile
150 | :)
151 | declare function api:lock-print($request as map(*)) as map(*) {
152 | try {
153 | let $config := api:get-collection-config($request?parameters?collection)
154 | let $lockfile := $config?path || '/' || config:lock()
155 | let $message :=
156 | if (not(doc-available($lockfile)))
157 | then "No lockfile for '" || $config?collection || "' found."
158 | else doc($lockfile)/task/value/string() || " in progress"
159 |
160 | return map { "message": $message }
161 | }
162 | catch * {
163 | api:catch-error("api:lock-print", map {
164 | "code": $err:code, "description": $err:description, "value": $err:value,
165 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
166 | })
167 | }
168 | };
169 |
170 | (:~
171 | : Load repository state to staging collection
172 | :)
173 | declare function api:git-pull($request as map(*)) as map(*) {
174 | api:pull(
175 | api:get-collection-config($request?parameters?collection),
176 | $request?parameters?hash)
177 | };
178 |
179 | (:~
180 | : Load default repository state to staging collection
181 | :)
182 | declare function api:git-pull-default($request as map(*)) as map(*) {
183 | api:pull(
184 | api:get-default-collection-config(),
185 | $request?parameters?hash)
186 | };
187 |
188 | (:~
189 | : Load repository state to staging collection
190 | :)
191 | declare %private function api:pull($config as map(*), $hash as xs:string?) as map(*) {
192 | try {
193 | if (doc-available($config?collection || "/" || config:lock())) then (
194 | roaster:response(403, "application/json", map {
195 | "message" : doc($config?collection || "/" || config:lock())/task/value/text() || " in progress"
196 | })
197 | )
198 | else (
199 | let $actions := vcs:get-actions($config?type)
200 | let $write-lock := app:lock-write($config?collection, "git-pull")
201 |
202 | let $staging-collection := $config?path || config:suffix()
203 |
204 | let $delete-collection := collection:remove($staging-collection, true())
205 | let $create-collection := collection:create($staging-collection)
206 |
207 | let $commit :=
208 | if (exists($hash)) then (
209 | $actions?get-specific-commit($config, $hash)
210 | ) else (
211 | $actions?get-last-commit($config)
212 | )
213 |
214 | let $zip := $actions?get-archive($config, $commit?sha)
215 | let $extract := app:extract-archive($zip, $staging-collection)
216 | let $_ := app:write-commit-info($staging-collection, $commit)
217 |
218 | let $remove-lock := app:lock-remove($config?collection)
219 |
220 | return map {
221 | "message" : "success",
222 | "hash": $commit?sha,
223 | "collection": $staging-collection
224 | }
225 | )
226 | }
227 | catch * {
228 | api:catch-error("api:git-deploy", map {
229 | "code": $err:code, "description": $err:description, "value": $err:value,
230 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
231 | })
232 | }
233 | };
234 |
235 | (:~
236 | : Deploy Repo from staging collection to its final destination
237 | :)
238 | declare function api:git-deploy($request as map(*)) as map(*) {
239 | try {
240 | let $config := api:get-collection-config($request?parameters?collection)
241 | let $destination := $config?path
242 | let $lockfile := $config?path || "/" || config:lock()
243 | let $staging := $config?path || config:suffix()
244 |
245 | let $ensure-destination-collection := collection:create($destination)
246 | return
247 | if (not(xmldb:collection-available($staging)))
248 | then map { "message" : "Staging collection '" || $staging || "' does not exist!" }
249 | else if (doc-available($lockfile))
250 | then map { "message" : doc($lockfile)/task/value/text() || " in progress!" }
251 | else if (exists($ensure-destination-collection?error))
252 | then map { "message" : "Could not create destination collection!", "error": $ensure-destination-collection?error }
253 | else
254 | let $write-lock := app:lock-write($destination, "deploy")
255 | let $is-expath-package := xmldb:get-child-resources($staging) = ("expath-pkg.xml")
256 | let $deploy :=
257 | if ($is-expath-package)
258 | then (
259 | let $package := doc(concat($staging, "/expath-pkg.xml"))//@name/string()
260 | let $remove-pkg :=
261 | if ($package = repo:list())
262 | then (
263 | let $undeploy := repo:undeploy($package)
264 | let $remove := repo:remove($package)
265 | return ($undeploy, $remove)
266 | )
267 | else ()
268 |
269 | let $xar :=
270 | xmldb:store-as-binary(
271 | $staging, "pkg.xar",
272 | compression:zip(xs:anyURI($staging), true(), $staging))
273 |
274 | let $install := repo:install-and-deploy-from-db($xar)
275 | return "package installation"
276 | )
277 | else (
278 | let $cleanup-col := app:cleanup-collection($destination)
279 | let $move-col := app:move-collection($staging, $destination)
280 | let $set-permissions := app:set-permission($destination)
281 | return "data move"
282 | )
283 |
284 | let $remove-staging := collection:remove($staging, true())
285 | let $remove-lock := app:lock-remove($destination)
286 | let $reindex := xmldb:reindex($destination)
287 |
288 | return map {
289 | "hash": config:deployed-sha($destination),
290 | "message": "success"
291 | }
292 |
293 | }
294 | catch * {
295 | api:catch-error("api:git-deploy", map {
296 | "code": $err:code, "description": $err:description, "value": $err:value,
297 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
298 | })
299 | }
300 | };
301 |
302 | (:~
303 | : get commits and comments
304 | :)
305 | declare function api:get-commits($request as map(*)) as map(*) {
306 | try {
307 | let $config := api:get-collection-config($request?parameters?collection)
308 | let $actions := vcs:get-actions($config?type)
309 |
310 | return map {
311 | 'commits': $actions?get-commits($config, $request?parameters?count)
312 | }
313 | }
314 | catch * {
315 | api:catch-error("api:git-commits", map {
316 | "code": $err:code, "description": $err:description, "value": $err:value,
317 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
318 | })
319 | }
320 | };
321 |
322 | (:~
323 | : get commits and comments
324 | :)
325 | declare function api:get-commits-default($request as map(*)) as map(*) {
326 | try {
327 | let $config := api:get-default-collection-config()
328 | let $actions := vcs:get-actions($config?type)
329 |
330 | return map {
331 | 'commits': $actions?get-commits($config, $request?parameters?count)
332 | }
333 | }
334 | catch * {
335 | api:catch-error("api:git-commits-default", map {
336 | "code": $err:code, "description": $err:description, "value": $err:value,
337 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
338 | })
339 | }
340 | };
341 |
342 | (:~
343 | : Trigger incremental update
344 | :)
345 | declare function api:incremental($request as map(*)) as map(*) {
346 | try {
347 | let $config := api:get-collection-config($request?parameters?collection)
348 | let $lockfile := $config?path || "/" || config:lock()
349 | let $actions := vcs:get-actions($config?type)
350 |
351 | let $extend-str := function ($result as xs:string) {
352 | map{ "path": $result }
353 | }
354 | let $extend-arr := function ($result as array(*)) {
355 | map{
356 | "path": $result?1,
357 | "success": $result?2,
358 | "error": if ($result?2) then () else $result?3
359 | }
360 | }
361 |
362 | return
363 | if (not(xmldb:collection-available($config?path))) then (
364 | roaster:response(403, map { "message" : "Destination collection not exist" })
365 | )
366 | else if (empty($config?deployed)) then (
367 | roaster:response(403, map { "message" : "Collection not managed by Tuttle" })
368 | )
369 | else if ($request?parameters?dry) then
370 | map {
371 | "changes" :
372 | let $changes := $actions?incremental-dry($config)
373 | return map {
374 | 'new': array:for-each($changes?new, $extend-str),
375 | 'del': array:for-each($changes?del, $extend-str),
376 | 'ignored': array:for-each($changes?ignored, $extend-str)
377 | },
378 | "message" : "dry-run"
379 | }
380 | else if (doc-available($lockfile)) then (
381 | roaster:response(403, map { "message" : doc($lockfile)/task/value/text() || " in progress" })
382 | )
383 | else (
384 | let $write-lock := app:lock-write($config?path, "incremental")
385 |
386 | let $incremental :=
387 | let $changes := $actions?incremental($config)
388 | return map {
389 | 'new': array:for-each($changes?new, $extend-arr),
390 | 'del': array:for-each($changes?del, $extend-arr),
391 | 'ignored': array:for-each($changes?ignored, $extend-str)
392 | }
393 |
394 | (: run callback if configured :)
395 | let $callback := config:get-callback($config)
396 |
397 | let $callback-result :=
398 | if (exists($callback)) then
399 | try {
400 | map { "result": $callback($config, $incremental), "success": true() }
401 | } catch * {
402 | map {
403 | "result": (),
404 | "success": false(),
405 | "error": map{
406 | "code": $err:code,
407 | "description": $err:description
408 | }
409 | }
410 | }
411 | else ()
412 |
413 | (:
414 | Check if any of the previous additions, deletions or callback did not succeed
415 | Each action is an array with [result, success (, error)]
416 | :)
417 | let $results := ($incremental?new?*, $incremental?del?*, $callback-result)
418 | let $all-errored-operations := filter($results, function ($a) { exists($a?error) })
419 |
420 | (: Q: should the lock be upheld in case of errors? :)
421 | return
422 | if (exists($all-errored-operations)) then (
423 | roaster:response(500, "application/json", map {
424 | "hash": config:deployed-sha($config?path),
425 | "message": "ended with errors",
426 | "changes": $incremental,
427 | "callback": $callback-result,
428 | "errors" : array{ $all-errored-operations }
429 | })
430 | ) else (
431 | app:lock-remove($config?path),
432 | map {
433 | "hash": config:deployed-sha($config?path),
434 | "message": "success",
435 | "changes": $incremental,
436 | "callback": $callback-result
437 | }
438 | )
439 | )
440 | }
441 | catch * {
442 | api:catch-error("api:incremental", map {
443 | "code": $err:code, "description": $err:description, "value": $err:value,
444 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
445 | })
446 | }
447 | };
448 |
449 | (:~
450 | : APIKey generation for webhooks
451 | :)
452 | declare function api:api-keygen($request as map(*)) as map(*) {
453 | try {
454 | let $config := api:get-collection-config($request?parameters?collection)
455 | let $apikey := app:random-key(42)
456 | let $write-apikey := app:write-apikey($config?collection, $apikey)
457 |
458 | return map { "APIKey" : $apikey }
459 | }
460 | catch * {
461 | api:catch-error("api:api-keygen", map {
462 | "code": $err:code, "description": $err:description, "value": $err:value,
463 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
464 | })
465 | }
466 | };
467 |
468 | (:~
469 | : Webhook function
470 | :)
471 | declare function api:hook($request as map(*)) as map(*) {
472 | try {
473 | let $config := api:get-collection-config($request?parameters?collection)
474 | let $apikey := doc(config:apikeys())//apikeys/collection[name = $config?collection]/key/string()
475 | let $lockfile := $config?path || "/" || config:lock()
476 | let $actions := vcs:get-actions($config?type)
477 |
478 | return
479 | if (empty($apikey)) then (
480 | roaster:response(403, "application/json", map { "message": "apikey does not exist" })
481 | )
482 | else if (doc-available($lockfile)) then (
483 | roaster:response(403, "application/json", map { "message" : doc($lockfile)/task/value/text() || " in progress" })
484 | )
485 | else if (not($actions?check-signature($config?collection, $apikey))) then (
486 | roaster:response(401, "application/json", map { "message": "Unauthorized"})
487 | )
488 | else (
489 | let $collection-destination-sha := $config?path || "/gitsha.xml"
490 | let $login := xmldb:login($config?path, $config?hookuser, $config?hookpasswd)
491 | let $write-lock := app:lock-write($config?path, "hook")
492 |
493 | let $incremental := $actions?incremental($config)
494 |
495 | let $remove-lock := app:lock-remove($config?path)
496 |
497 | return
498 | map {
499 | "sha": config:deployed-sha($config?path),
500 | "message": "success"
501 | }
502 | )
503 | }
504 | catch * {
505 | api:catch-error("api:hook", map {
506 | "code": $err:code, "description": $err:description, "value": $err:value,
507 | "line": $err:line-number, "column": $err:column-number, "module": $err:module
508 | })
509 | }
510 | };
511 |
512 | (:~
513 | : This is used as an error-handler in the API definition
514 | :)
515 | declare function api:handle-error($error as map(*)) as element(html) {
516 |
517 |
518 | Error [{$error?code}]
519 | {
520 | if (map:contains($error, "module"))
521 | then ``[An error occurred in `{$error?module}` at line `{$error?line}`, column `{$error?column}`]``
522 | else "An error occurred!"
523 | }
524 | Description
525 | {$error?description}
526 |
527 |
528 | };
529 |
530 | declare %private
531 | function api:catch-error($function, $error as map(*)) {
532 | util:log('error', ($function, ': [', $error?code, '] ', $error?description)),
533 | roaster:response(500, "application/json", map {
534 | "message": $error?description, "error": $error
535 | })
536 | };
537 |
538 | declare %private function api:get-default-collection-config() as map(*)? {
539 | config:collections(config:default-collection())
540 | };
541 |
542 | declare %private function api:get-collection-config($collection as xs:string?) as map(*)? {
543 | let $git-collection :=
544 | if (exists($collection) and $collection ne '')
545 | then xmldb:decode-uri($collection)
546 | else config:default-collection()
547 |
548 | let $collection-config := config:collections($git-collection)
549 |
550 | return
551 | if (empty($git-collection))
552 | then error((), "git collection not found!")
553 | else if (empty($collection-config))
554 | then error((), "collection config " || $git-collection || " not found!")
555 | else $collection-config
556 | };
557 |
558 | (: end of route handlers :)
559 |
560 | (:~
561 | : This function "knows" all modules and their functions
562 | : that are imported here
563 | : You can leave it as it is, but it has to be here
564 | :)
565 | declare function api:lookup ($name as xs:string) {
566 | function-lookup(xs:QName($name), 1)
567 | };
568 |
569 | roaster:route($api:definitions, api:lookup#1)
570 |
--------------------------------------------------------------------------------