diff --git a/AI-prompt.md b/AI-prompt.md new file mode 100644 index 0000000..23bec37 --- /dev/null +++ b/AI-prompt.md @@ -0,0 +1,16 @@ +Build an x0 application using the x0 framework from https://github.com/WEBcodeX1/x0, +The backend in Python should use the https://github.com/clauspruefer/python-micro-esb project +The CSS styling is done with the x0 contained Bootstrap default theme and should look modern. +The browser-frontend consists of 3 x0 screens / 3 menu entries: +1. Screen1 "User Credentials": contains 2 groups of formfields (x0 object of type `sysObjectFormfieldList`): group a) GitHub API User/Token credentials group b) Stackfield API User/Token Credentials +1.1. On each formfield group there must be a button "verify user credentials" to test if the API authentication is working +2. Screen2 "Issue / Task Mapping": contains the mapping between GitHub Issues and Stackfield Tasks +2.1. On top there should be 1 formfield / search input and a search execution button (x0 object of type `sysObjectFormfieldList`) +2.2. Under the search must be a list (x0 object of type `sysObjectList` containing the search results +2.3. The search result list from 2.2. contains a x0 object of type `sysObjContextMenu` with one entry "Connect Stackfield Task" +2.4. On button click from 2.1 the relevant python-micro-esb backend service must be called and the given result list from 2.2 must be filled with the result data from the backend service +3. Screen3 "Connect Stackfield Task" +3.1. Contains detailed GitHub Task properties from GitHub API webservice result (encapsulated from python-micro-esb backend), e.g. 3 or four formfields describing the task properties from API result +3.2. On right click of screen 2 object result display list single row, the GitHub task properties fields of Screen3 will be updated from the row data +3.3. Also there should be relevant Stackfield properties where to map the Stackfield Task (Stackfield room or similar, please find the best possible fields from the API to map) +3.4. At the bottom there must be 1 button (x0 object type `sysObjButton`), on click a NEW STACKFIELD TASK will be generated from the relevant GitHub Issue/Task data from Screen3 formfield data. diff --git a/README.md b/README.md new file mode 100644 index 0000000..98b7b57 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# github2stackfield + +**github2stackfield** is an x0 web application that bridges **GitHub Issues** with **Stackfield Tasks**. +It lets you search GitHub issues, inspect their details, and create corresponding Stackfield tasks — all from a clean, Bootstrap-styled browser frontend. + +--- + +## Architecture + +| Layer | Technology | +|---|---| +| Browser frontend | [x0 JavaScript framework](https://github.com/WEBcodeX1/x0) with Bootstrap default theme | +| Backend services | Python WSGI scripts via [python-micro-esb](https://github.com/clauspruefer/python-micro-esb) | +| Database | PostgreSQL (shared x0 instance) | +| Deployment | Apache2 + mod_wsgi (Docker or bare-metal) | + +--- + +## Screens + +### Screen 1 — User Credentials +Configure and verify API access for both platforms. + +- **GitHub API Credentials** — enter your GitHub username and Personal Access Token. + Click *Verify GitHub Credentials* to validate and store them. +- **Stackfield API Credentials** — enter your Stackfield e-mail and API token. + Click *Verify Stackfield Credentials* to validate and store them. + +### Screen 2 — Issue / Task Mapping +Search GitHub issues and select one to map to Stackfield. + +1. Enter the target repository (`owner/repository`) and an optional search term. +2. Click **Search Issues** — results populate the issue list below. +3. Right-click any row and select **Connect Stackfield Task** to navigate to Screen 3. + +### Screen 3 — Connect Stackfield Task +Review the selected GitHub issue and create a Stackfield task. + +- **GitHub Issue Properties** — read-only fields: issue number, state, title, URL. +- **Stackfield Task Mapping** — editable fields pre-populated from the issue: + - *Stackfield Room ID* — the target Stackfield room / channel identifier. + - *Task Title* — editable, defaults to the GitHub issue title. + - *Description* — editable, defaults to the GitHub issue body. + - *Priority* — Low / Medium / High / Urgent. +- Click **Create New Stackfield Task** — a new task is created in Stackfield via the REST API. + +--- + +## Prerequisites + +| Requirement | Notes | +|---|---| +| Docker Engine | With Compose V2 (`docker compose`) | +| GitHub Personal Access Token | Needs `repo` scope for private repos, `public_repo` for public | +| Stackfield API token | See Stackfield workspace settings → Integrations → API | + +--- + +## Docker (quick start) + +The application uses the official x0 container images: + +- **`ghcr.io/webcodex1/x0-app`** — Apache2 + mod_wsgi web application server with the x0 JavaScript framework pre-installed ([packages page](https://github.com/WEBcodeX1/x0/pkgs/container/x0-app)) +- **`ghcr.io/webcodex1/x0-db`** — PostgreSQL 16 database with the x0 schema pre-installed ([packages page](https://github.com/WEBcodeX1/x0/pkgs/container/x0-db)) + +Both custom images are built automatically on `docker compose up --build`: + +- `docker/Dockerfile` extends `ghcr.io/webcodex1/x0-app` and copies all static files, Python WSGI scripts, and the Apache2 config snippet into the image. +- `docker/Dockerfile.db` extends `ghcr.io/webcodex1/x0-db` and runs the github2stackfield SQL init scripts (`database/0*.sql`) against the x0 database on first start. + +```bash +cd docker +docker compose up --build +``` + +Then open [http://localhost:8080/?appid=github2sf](http://localhost:8080/?appid=github2sf). + +No further manual configuration is required. + +--- + +## Project structure + +``` +github2stackfield/ +├── static/ +│ ├── menu.json # x0 navigation menu definition +│ ├── object.json # x0 UI objects (formfields, lists, buttons …) +│ └── skeleton.json # x0 screen layout +├── python/ +│ ├── service_implementation.py # GitHubService + StackfieldService ClassHandlers +│ ├── user_routing.py # python-micro-esb ServiceRouter routing functions +│ ├── VerifyGitHubCredentials.py # WSGI – verify GitHub credentials +│ ├── VerifyStackfieldCredentials.py # WSGI – verify Stackfield credentials +│ ├── SearchGitHubIssues.py # WSGI – search issues, populate list +│ ├── GetGitHubIssueDetails.py # WSGI – fetch issue details for Screen 3 +│ ├── CreateStackfieldTask.py # WSGI – create Stackfield task +│ ├── POSTData.py # x0 POST body reader helper +│ └── StdoutLogger.py # logging helper +├── database/ +│ ├── 01-create-schema.sql # github2sf schema + tables +│ ├── 02-insert-config.sql # x0 app configuration rows +│ └── 03-insert-text.sql # UI text / i18n entries +├── docker/ +│ ├── Dockerfile # extends x0-app, bakes in static/ python/ apache2.conf +│ ├── Dockerfile.db # extends x0-db, auto-inits github2sf schema on startup +│ ├── docker-compose.yml +│ ├── db-init.sh # startup script used by Dockerfile.db +│ └── apache2.conf +└── README.md +``` + +--- + +## python-micro-esb integration + +The backend services are built on the [python-micro-esb](https://github.com/clauspruefer/python-micro-esb) framework: + +- **`service_implementation.py`** — contains `GitHubService` and `StackfieldService`, both subclassing `microesb.ClassHandler`. Each class exposes service methods (`verify`, `search_issues`, `get_issue_details`, `create_task`). +- **`user_routing.py`** — routing functions consumed by `ServiceRouter.send()`. Each function instantiates the appropriate service class, calls the relevant method, and returns the result. +- **WSGI scripts** — thin wrappers that read the x0 POST payload, call `ServiceRouter.send()`, and return JSON to the x0 frontend. + +--- + +## Stackfield API notes + +Stackfield's REST API is available at `https://www.stackfield.com/api/v1/`. +Key endpoints used: + +| Endpoint | Purpose | +|---|---| +| `GET /v1/user` | Verify credentials | +| `POST /v1/rooms/{room_id}/tasks` | Create a new task | + +The **Room ID** can be found in Stackfield under *Room settings → General → Room ID* or via the URL slug. + +--- + +## License + +See [LICENSE](LICENSE). diff --git a/database/01-create-schema.sql b/database/01-create-schema.sql new file mode 100644 index 0000000..7276a4b --- /dev/null +++ b/database/01-create-schema.sql @@ -0,0 +1,59 @@ +-- ]*[ ------------------------------------------------------------------ ]*[ +-- . github2stackfield - Database Schema . +-- ]*[ ------------------------------------------------------------------ ]*[ +-- . . +-- . Run against an existing x0 PostgreSQL database. . +-- . The x0 framework must be set up first (see x0 repository). . +-- . . +-- ]*[ ------------------------------------------------------------------ ]*[ + +-- Application schema +CREATE SCHEMA IF NOT EXISTS github2sf; + +-- --------------------------------------------------------------------------- +-- API Credentials storage +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS github2sf.credentials ( + credential_type VARCHAR(20) NOT NULL, + username_or_email TEXT NOT NULL, + api_token TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT pk_credentials PRIMARY KEY (credential_type) +); + +COMMENT ON TABLE github2sf.credentials IS + 'Stores GitHub and Stackfield API credentials (one row per credential_type).'; + +-- --------------------------------------------------------------------------- +-- Application configuration key-value store +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS github2sf.app_config ( + config_key VARCHAR(100) NOT NULL, + value TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT pk_app_config PRIMARY KEY (config_key) +); + +COMMENT ON TABLE github2sf.app_config IS + 'Key-value store for github2stackfield application runtime configuration.'; + +-- --------------------------------------------------------------------------- +-- Issue / Task mapping log +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS github2sf.issue_task_mapping ( + id SERIAL NOT NULL, + github_repo TEXT NOT NULL, + github_issue_number INTEGER NOT NULL, + github_issue_title TEXT, + stackfield_room_id TEXT NOT NULL, + stackfield_task_id TEXT, + stackfield_task_url TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT pk_issue_task_mapping PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS idx_issue_task_mapping_repo_issue + ON github2sf.issue_task_mapping (github_repo, github_issue_number); + +COMMENT ON TABLE github2sf.issue_task_mapping IS + 'Audit log of all GitHub issue → Stackfield task mappings created by the app.'; diff --git a/database/02-insert-config.sql b/database/02-insert-config.sql new file mode 100644 index 0000000..054280f --- /dev/null +++ b/database/02-insert-config.sql @@ -0,0 +1,22 @@ +-- ]*[ ------------------------------------------------------------------ ]*[ +-- . github2stackfield - x0 Application Configuration . +-- ]*[ ------------------------------------------------------------------ ]*[ +-- . . +-- . Insert the x0 system.config rows for the github2stackfield app. . +-- . Adjust app_id value if your x0 setup uses a different identifier. . +-- . . +-- ]*[ ------------------------------------------------------------------ ]*[ + +-- Remove any previously inserted config for this app +DELETE FROM system.config WHERE app_id = 'github2sf'; + +INSERT INTO system.config (app_id, config_group, "value") VALUES + ('github2sf', 'index_title', 'GitHub ↔ Stackfield Connector'), + ('github2sf', 'debug_level', '0'), + ('github2sf', 'display_language', 'en'), + ('github2sf', 'default_screen', 'Screen1'), + ('github2sf', 'parent_window_url', 'null'), + ('github2sf', 'subdir', '/static/github2sf'), + ('github2sf', 'config_file_menu', 'menu.json'), + ('github2sf', 'config_file_object', 'object.json'), + ('github2sf', 'config_file_skeleton','skeleton.json'); diff --git a/database/03-insert-text.sql b/database/03-insert-text.sql new file mode 100644 index 0000000..3235890 --- /dev/null +++ b/database/03-insert-text.sql @@ -0,0 +1,116 @@ +-- ]*[ ------------------------------------------------------------------ ]*[ +-- . github2stackfield - UI Text / Localisation . +-- ]*[ ------------------------------------------------------------------ ]*[ +-- . . +-- . Insert into the x0 webui.text table. . +-- . Both English and German translations are provided. . +-- . . +-- ]*[ ------------------------------------------------------------------ ]*[ + +-- Navigation / Menu +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.MENU.SCREEN1', 'menu', 'API-Zugangsdaten', 'User Credentials') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.MENU.SCREEN2', 'menu', 'Issue / Aufgaben-Mapping', 'Issue / Task Mapping') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.MENU.SCREEN3', 'menu', 'Stackfield-Aufgabe verbinden', 'Connect Stackfield Task') ON CONFLICT (id) DO NOTHING; + +-- Screen 1 – GitHub Credentials +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.GITHUB.SECTION.HEADER', 'screen1', 'GitHub API-Zugangsdaten', 'GitHub API Credentials') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.GITHUB.SECTION.SUBHEADER', 'screen1', 'Benutzername und Personal Access Token eingeben', 'Enter your GitHub username and Personal Access Token') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.GITHUB.USER.LABEL', 'screen1', 'GitHub Benutzername', 'GitHub Username') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.GITHUB.TOKEN.LABEL', 'screen1', 'GitHub Personal Access Token', 'GitHub Personal Access Token') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.GITHUB.VERIFY.BUTTON', 'screen1', 'GitHub Zugangsdaten prüfen', 'Verify GitHub Credentials') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.GITHUB.VERIFY.NOTIFY', 'screen1', 'GitHub Authentifizierung', 'GitHub Authentication') ON CONFLICT (id) DO NOTHING; + +-- Screen 1 – Stackfield Credentials +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.STACKFIELD.SECTION.HEADER', 'screen1', 'Stackfield API-Zugangsdaten', 'Stackfield API Credentials') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.STACKFIELD.SECTION.SUBHEADER', 'screen1', 'E-Mail-Adresse und API-Token eingeben', 'Enter your Stackfield email and API token') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.STACKFIELD.EMAIL.LABEL', 'screen1', 'Stackfield E-Mail', 'Stackfield Email') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.STACKFIELD.TOKEN.LABEL', 'screen1', 'Stackfield API-Token', 'Stackfield API Token') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.STACKFIELD.VERIFY.BUTTON', 'screen1', 'Stackfield Zugangsdaten prüfen', 'Verify Stackfield Credentials') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN1.STACKFIELD.VERIFY.NOTIFY', 'screen1', 'Stackfield Authentifizierung', 'Stackfield Authentication') ON CONFLICT (id) DO NOTHING; + +-- Screen 2 – Search +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.SEARCH.SECTION.HEADER', 'screen2', 'GitHub Issues suchen', 'Search GitHub Issues') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.SEARCH.SECTION.SUBHEADER', 'screen2', 'Repository und Suchbegriff eingeben', 'Enter the repository and an optional search term') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.SEARCH.QUERY.LABEL', 'screen2', 'Suchbegriff (optional)', 'Search query (optional)') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.SEARCH.REPO.LABEL', 'screen2', 'Repository (owner/repo)', 'Repository (owner/repo)') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.SEARCH.BUTTON', 'screen2', 'Issues suchen', 'Search Issues') ON CONFLICT (id) DO NOTHING; + +-- Screen 2 – Issue List columns +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.LIST.COL.NUMBER', 'screen2', 'Nr.', '#') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.LIST.COL.TITLE', 'screen2', 'Titel', 'Title') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.LIST.COL.STATE', 'screen2', 'Status', 'State') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.LIST.COL.CREATED', 'screen2', 'Erstellt', 'Created') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.LIST.COL.ASSIGNEE', 'screen2', 'Zugewiesen', 'Assignee') ON CONFLICT (id) DO NOTHING; + +-- Screen 2 – Context menu +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN2.CONTEXTMENU.CONNECT', 'screen2', 'Stackfield-Aufgabe verbinden', 'Connect Stackfield Task') ON CONFLICT (id) DO NOTHING; + +-- Screen 3 – GitHub Issue Details +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.ISSUE.SECTION.HEADER', 'screen3', 'GitHub Issue Eigenschaften', 'GitHub Issue Properties') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.ISSUE.SECTION.SUBHEADER', 'screen3', 'Daten aus der GitHub API', 'Data from the GitHub API') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.ISSUE.NUMBER.LABEL', 'screen3', 'Issue-Nummer', 'Issue Number') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.ISSUE.STATE.LABEL', 'screen3', 'Status', 'State') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.ISSUE.TITLE.LABEL', 'screen3', 'Titel', 'Title') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.ISSUE.URL.LABEL', 'screen3', 'GitHub URL', 'GitHub URL') ON CONFLICT (id) DO NOTHING; + +-- Screen 3 – Stackfield Mapping +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.STACKFIELD.SECTION.HEADER', 'screen3', 'Stackfield Aufgaben-Zuordnung', 'Stackfield Task Mapping') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.STACKFIELD.SECTION.SUBHEADER', 'screen3', 'Ziel-Raum und Aufgaben-Details', 'Target room and task details') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.STACKFIELD.ROOM.LABEL', 'screen3', 'Stackfield Raum-ID', 'Stackfield Room ID') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.TASK.TITLE.LABEL', 'screen3', 'Aufgaben-Titel', 'Task Title') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.TASK.DESC.LABEL', 'screen3', 'Beschreibung', 'Description') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.TASK.PRIORITY.LABEL', 'screen3', 'Priorität', 'Priority') ON CONFLICT (id) DO NOTHING; + +-- Screen 3 – Priority options +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.PRIORITY.LOW', 'screen3', 'Niedrig', 'Low') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.PRIORITY.MEDIUM', 'screen3', 'Mittel', 'Medium') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.PRIORITY.HIGH', 'screen3', 'Hoch', 'High') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.PRIORITY.URGENT', 'screen3', 'Dringend', 'Urgent') ON CONFLICT (id) DO NOTHING; + +-- Screen 3 – Create button +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.CREATE.BUTTON', 'screen3', 'Neue Stackfield-Aufgabe erstellen', 'Create New Stackfield Task') ON CONFLICT (id) DO NOTHING; +INSERT INTO webui.text (id, "group", value_de, value_en) VALUES + ('TXT.SCREEN3.CREATE.NOTIFY', 'screen3', 'Stackfield Aufgabe erstellen', 'Create Stackfield Task') ON CONFLICT (id) DO NOTHING; diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..c69c9a6 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,31 @@ +FROM ghcr.io/webcodex1/x0-app + +# Install github2stackfield Python backend dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3-pip \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN pip3 install --no-cache-dir --break-system-packages \ + requests \ + psycopg2-binary \ + pgdbpool \ + git+https://github.com/clauspruefer/python-micro-esb.git + +# Copy static x0 config files into the web-server document root +COPY static/ /var/www/vhosts/x0/static/github2sf/ + +# Link the x0 base stylesheets and fontawesome into the app subdir so that +# the x0 framework can find bootstrap.css, globalstyles.css and all.min.css +# at the {subdir}/ path it generates in the HTML tags. +# globalstyles.css is copied (not symlinked) so that app-specific overrides +# for notification indicator styles (IndicatorSuccess, IndicatorWarning, ...) +# are included. +RUN ln -s /var/www/vhosts/x0/static/bootstrap.css /var/www/vhosts/x0/static/github2sf/bootstrap.css \ + && ln -s /var/www/vhosts/x0/static/fontawesome /var/www/vhosts/x0/static/github2sf/fontawesome + +# Copy Python WSGI backend scripts into the x0 python directory +COPY python/ /var/www/vhosts/x0/python/github2sf/ + +# Install the supplementary Apache2 configuration snippet +COPY docker/apache2.conf /etc/apache2/conf-enabled/github2sf.conf diff --git a/docker/Dockerfile.db b/docker/Dockerfile.db new file mode 100644 index 0000000..83bea28 --- /dev/null +++ b/docker/Dockerfile.db @@ -0,0 +1,11 @@ +FROM ghcr.io/webcodex1/x0-db + +# Copy the github2stackfield SQL init scripts into the container +COPY database/ /opt/github2sf-init/ + +# Copy the custom startup script and make it executable +COPY docker/db-init.sh /root/github2sf-start.sh +RUN chmod +x /root/github2sf-start.sh + +# Override the default x0-db startup with our initialising script +CMD ["/root/github2sf-start.sh"] diff --git a/docker/apache2.conf b/docker/apache2.conf new file mode 100644 index 0000000..b0f3854 --- /dev/null +++ b/docker/apache2.conf @@ -0,0 +1,17 @@ +# github2stackfield supplementary Apache configuration +# Mounted into /etc/apache2/conf-enabled/ inside the x0-app container. +# The x0-app base image already configures Apache2 + mod_wsgi and sets +# DocumentRoot /var/www/vhosts/x0 with WSGI handling for the python/ tree. +# This snippet grants explicit directory access to the app-specific paths. + + + Options -Indexes + Require all granted + + + + AddHandler wsgi-script .py + Options ExecCGI + AllowOverride None + Require all granted + diff --git a/docker/db-init.sh b/docker/db-init.sh new file mode 100755 index 0000000..02172e2 --- /dev/null +++ b/docker/db-init.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - x0-db automatic database initialisation . +# . . +# . Replaces the default x0-db start script. . +# . Starts PostgreSQL, waits until it accepts connections, applies the . +# . github2stackfield SQL scripts against the x0 database, then keeps . +# . the server running. . +# ]*[ --------------------------------------------------------------------- ]*[ +set -e + +chown -R postgres:postgres /var/lib/postgresql/16/main/ +chown -R postgres:postgres /var/run/postgresql/ + +# Start PostgreSQL in the background +su -c "/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main" postgres & +PG_PID=$! + +# Wait until PostgreSQL is ready to accept connections +echo "Waiting for PostgreSQL to be ready..." +until su -c "pg_isready -q -U postgres -d x0" postgres 2>/dev/null; do + sleep 1 +done +echo "PostgreSQL is ready." + +# Apply github2stackfield database init scripts to the x0 database. +# All scripts are idempotent and safe to re-run on container restart. +echo "Applying github2stackfield database init scripts..." +su -c "psql -d x0 -f /opt/github2sf-init/01-create-schema.sql" postgres +su -c "psql -d x0 -f /opt/github2sf-init/02-insert-config.sql" postgres +su -c "psql -d x0 -f /opt/github2sf-init/03-insert-text.sql" postgres +echo "Database initialisation complete." + +# Keep the container alive by waiting for the PostgreSQL process +wait $PG_PID diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..bb942dc --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,49 @@ +services: + + # --------------------------------------------------------------------------- + # x0 database container — extends the official x0-db image and + # automatically initialises the github2stackfield schema + data on startup. + # Official base: https://github.com/WEBcodeX1/x0/pkgs/container/x0-db + # --------------------------------------------------------------------------- + x0-db: + build: + context: .. + dockerfile: docker/Dockerfile.db + container_name: github2sf-db + restart: unless-stopped + ports: + - "5432:5432" + networks: + github2sf-net: + ipv4_address: 172.20.0.20 + aliases: + - mypostgres + + # --------------------------------------------------------------------------- + # x0 application server — extends the official x0-app image and + # bundles all static / Python backend / Apache config at build time. + # Official base: https://github.com/WEBcodeX1/x0/pkgs/container/x0-app + # --------------------------------------------------------------------------- + x0-app: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: github2sf-app + restart: unless-stopped + depends_on: + - x0-db + extra_hosts: + - "mypostgres:172.20.0.20" + ports: + - "8080:80" + networks: + github2sf-net: + ipv4_address: 172.20.0.10 + +networks: + github2sf-net: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/24 + gateway: 172.20.0.1 diff --git a/python/CreateStackfieldTask.py b/python/CreateStackfieldTask.py new file mode 100644 index 0000000..795b8c0 --- /dev/null +++ b/python/CreateStackfieldTask.py @@ -0,0 +1,52 @@ +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - Create Stackfield Task WSGI Endpoint . +# ]*[ --------------------------------------------------------------------- ]*[ +# . . +# . Called by x0 CreateStackfieldTaskButton (OnClick). . +# . Creates a new Stackfield task from GitHub issue data collected on . +# . Screen 3. Stackfield credentials are loaded from the database. . +# . . +# ]*[ --------------------------------------------------------------------- ]*[ + +import sys +import json + +sys.path.insert(0, '/var/www/vhosts/x0/python/github2sf') + +import POSTData +from StdoutLogger import logger + +from microesb.router import ServiceRouter + + +def application(environ, start_response): + + start_response('200 OK', [('Content-Type', 'application/json; charset=UTF-8')]) + + if environ['REQUEST_METHOD'].upper() != 'POST': + yield bytes(json.dumps({'error': True, 'error_id': 400}), 'utf-8') + return + + try: + raw = POSTData.Environment.getPOSTData(environ) + service_json = json.loads(raw) + + request_data = service_json.get('RequestData', {}) + + logger.debug('CreateStackfieldTask RequestData:{}'.format(request_data)) + + router = ServiceRouter() + result = router.send('create_stackfield_task', request_data) + + logger.debug('CreateStackfieldTask result:{}'.format(result)) + + yield bytes(json.dumps(result), 'utf-8') + + except Exception as e: + logger.error('CreateStackfieldTask exception:{}'.format(e)) + error_result = { + 'error': True, + 'error_id': 500, + 'exception': str(e) + } + yield bytes(json.dumps(error_result), 'utf-8') diff --git a/python/GetGitHubIssueDetails.py b/python/GetGitHubIssueDetails.py new file mode 100644 index 0000000..f008efb --- /dev/null +++ b/python/GetGitHubIssueDetails.py @@ -0,0 +1,56 @@ +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - Get GitHub Issue Details WSGI Endpoint . +# ]*[ --------------------------------------------------------------------- ]*[ +# . . +# . Called by x0 IssueDetailsConnector (ServiceConnector) on Screen 3. . +# . Fetches full GitHub issue details and pre-populates Screen 3 . +# . formfields (IssueDetailsForm + StackfieldMappingForm task fields). . +# . . +# ]*[ --------------------------------------------------------------------- ]*[ + +import sys +import json + +sys.path.insert(0, '/var/www/vhosts/x0/python/github2sf') + +import POSTData +from StdoutLogger import logger + +from microesb.router import ServiceRouter + + +def application(environ, start_response): + + start_response('200 OK', [('Content-Type', 'application/json; charset=UTF-8')]) + + if environ['REQUEST_METHOD'].upper() != 'POST': + yield bytes(json.dumps({}), 'utf-8') + return + + try: + raw = POSTData.Environment.getPOSTData(environ) + service_json = json.loads(raw) + + request_data = service_json.get('RequestData', {}) + + logger.debug('GetGitHubIssueDetails RequestData:{}'.format(request_data)) + + router = ServiceRouter() + result = router.send('get_github_issue_details', request_data) + + logger.debug('GetGitHubIssueDetails result:{}'.format(result)) + + # x0 FormfieldList expects {"0": {...field values...}} + if isinstance(result, dict) and 0 in result: + result = {'0': result[0]} + + yield bytes(json.dumps(result), 'utf-8') + + except Exception as e: + logger.error('GetGitHubIssueDetails exception:{}'.format(e)) + error_result = { + 'error': True, + 'error_id': 500, + 'exception': str(e) + } + yield bytes(json.dumps(error_result), 'utf-8') diff --git a/python/POSTData.py b/python/POSTData.py new file mode 100644 index 0000000..92dc6be --- /dev/null +++ b/python/POSTData.py @@ -0,0 +1,9 @@ +import json + + +class Environment: + @staticmethod + def getPOSTData(environ): + ContentLength = int(environ.get('CONTENT_LENGTH', 0)) + Data = environ['wsgi.input'].read(ContentLength) + return Data diff --git a/python/SearchGitHubIssues.py b/python/SearchGitHubIssues.py new file mode 100644 index 0000000..f3dd07f --- /dev/null +++ b/python/SearchGitHubIssues.py @@ -0,0 +1,56 @@ +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - Search GitHub Issues WSGI Endpoint . +# ]*[ --------------------------------------------------------------------- ]*[ +# . . +# . Called by x0 IssueSearchConnector (ServiceConnector). . +# . Searches GitHub issues and returns indexed rows for the IssueList. . +# . Credentials are loaded from the database (set via Screen 1). . +# . . +# ]*[ --------------------------------------------------------------------- ]*[ + +import sys +import json + +sys.path.insert(0, '/var/www/vhosts/x0/python/github2sf') + +import POSTData +from StdoutLogger import logger + +from microesb.router import ServiceRouter + + +def application(environ, start_response): + + start_response('200 OK', [('Content-Type', 'application/json; charset=UTF-8')]) + + if environ['REQUEST_METHOD'].upper() != 'POST': + yield bytes(json.dumps({}), 'utf-8') + return + + try: + raw = POSTData.Environment.getPOSTData(environ) + service_json = json.loads(raw) + + request_data = service_json.get('RequestData', {}) + + logger.debug('SearchGitHubIssues RequestData:{}'.format(request_data)) + + router = ServiceRouter() + result = router.send('search_github_issues', request_data) + + logger.debug('SearchGitHubIssues result rows:{}'.format(len(result))) + + # Ensure result keys are strings (x0 List expects string-keyed indexed rows) + if isinstance(result, dict) and 'error' not in result: + result = {str(k): v for k, v in result.items()} + + yield bytes(json.dumps(result), 'utf-8') + + except Exception as e: + logger.error('SearchGitHubIssues exception:{}'.format(e)) + error_result = { + 'error': True, + 'error_id': 500, + 'exception': str(e) + } + yield bytes(json.dumps(error_result), 'utf-8') diff --git a/python/StdoutLogger.py b/python/StdoutLogger.py new file mode 100644 index 0000000..8618e2d --- /dev/null +++ b/python/StdoutLogger.py @@ -0,0 +1,5 @@ +import logging +import sys + +logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) +logger = logging.getLogger(__name__) diff --git a/python/VerifyGitHubCredentials.py b/python/VerifyGitHubCredentials.py new file mode 100644 index 0000000..8b5121c --- /dev/null +++ b/python/VerifyGitHubCredentials.py @@ -0,0 +1,51 @@ +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - Verify GitHub Credentials WSGI Endpoint . +# ]*[ --------------------------------------------------------------------- ]*[ +# . . +# . Called by x0 GitHubVerifyButton (OnClick). . +# . Validates GitHub API user/token and stores them in the database. . +# . . +# ]*[ --------------------------------------------------------------------- ]*[ + +import sys +import json + +sys.path.insert(0, '/var/www/vhosts/x0/python/github2sf') + +import POSTData +from StdoutLogger import logger + +from microesb.router import ServiceRouter + + +def application(environ, start_response): + + start_response('200 OK', [('Content-Type', 'application/json; charset=UTF-8')]) + + if environ['REQUEST_METHOD'].upper() != 'POST': + yield bytes(json.dumps({'error': True, 'error_id': 400}), 'utf-8') + return + + try: + raw = POSTData.Environment.getPOSTData(environ) + service_json = json.loads(raw) + + request_data = service_json.get('RequestData', {}) + + logger.debug('VerifyGitHubCredentials RequestData:{}'.format(request_data)) + + router = ServiceRouter() + result = router.send('verify_github', request_data) + + logger.debug('VerifyGitHubCredentials result:{}'.format(result)) + + yield bytes(json.dumps(result), 'utf-8') + + except Exception as e: + logger.error('VerifyGitHubCredentials exception:{}'.format(e)) + error_result = { + 'error': True, + 'error_id': 500, + 'exception': str(e) + } + yield bytes(json.dumps(error_result), 'utf-8') diff --git a/python/VerifyStackfieldCredentials.py b/python/VerifyStackfieldCredentials.py new file mode 100644 index 0000000..b2b7eb9 --- /dev/null +++ b/python/VerifyStackfieldCredentials.py @@ -0,0 +1,51 @@ +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - Verify Stackfield Credentials WSGI Endpoint . +# ]*[ --------------------------------------------------------------------- ]*[ +# . . +# . Called by x0 StackfieldVerifyButton (OnClick). . +# . Validates Stackfield API email/token and stores them in the database. . +# . . +# ]*[ --------------------------------------------------------------------- ]*[ + +import sys +import json + +sys.path.insert(0, '/var/www/vhosts/x0/python/github2sf') + +import POSTData +from StdoutLogger import logger + +from microesb.router import ServiceRouter + + +def application(environ, start_response): + + start_response('200 OK', [('Content-Type', 'application/json; charset=UTF-8')]) + + if environ['REQUEST_METHOD'].upper() != 'POST': + yield bytes(json.dumps({'error': True, 'error_id': 400}), 'utf-8') + return + + try: + raw = POSTData.Environment.getPOSTData(environ) + service_json = json.loads(raw) + + request_data = service_json.get('RequestData', {}) + + logger.debug('VerifyStackfieldCredentials RequestData:{}'.format(request_data)) + + router = ServiceRouter() + result = router.send('verify_stackfield', request_data) + + logger.debug('VerifyStackfieldCredentials result:{}'.format(result)) + + yield bytes(json.dumps(result), 'utf-8') + + except Exception as e: + logger.error('VerifyStackfieldCredentials exception:{}'.format(e)) + error_result = { + 'error': True, + 'error_id': 500, + 'exception': str(e) + } + yield bytes(json.dumps(error_result), 'utf-8') diff --git a/python/service_implementation.py b/python/service_implementation.py new file mode 100644 index 0000000..a536250 --- /dev/null +++ b/python/service_implementation.py @@ -0,0 +1,249 @@ +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - Service Implementation Classes . +# ]*[ --------------------------------------------------------------------- ]*[ +# . . +# . Provides ClassHandler-based service classes for GitHub and Stackfield . +# . API integration, used via python-micro-esb ServiceRouter routing. . +# . . +# ]*[ --------------------------------------------------------------------- ]*[ + +import logging +import requests + +from microesb import microesb + +logger = logging.getLogger(__name__) + + +class GitHubService(microesb.ClassHandler): + """GitHub API service handler. + + Provides methods to interact with the GitHub REST API v3 for + credential verification and issue retrieval. + """ + + BASE_URL = 'https://api.github.com' + + def __init__(self): + super().__init__() + self.github_user = None + self.github_token = None + self.repo = None + self.issue_number = None + self.search_query = None + self.result = {} + + def _get_auth(self): + return (self.github_user, self.github_token) + + def _get_headers(self): + return { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + + def verify(self): + """Verify GitHub credentials by calling the /user endpoint.""" + logger.debug('GitHubService.verify() user:{}'.format(self.github_user)) + try: + resp = requests.get( + '{}/user'.format(self.BASE_URL), + auth=self._get_auth(), + headers=self._get_headers(), + timeout=10 + ) + if resp.status_code == 200: + data = resp.json() + self.result = { + 'success': True, + 'login': data.get('login', ''), + 'name': data.get('name', ''), + 'public_repos': str(data.get('public_repos', 0)) + } + else: + self.result = { + 'success': False, + 'error': 'Authentication failed (HTTP {})'.format(resp.status_code) + } + except requests.exceptions.RequestException as e: + logger.error('GitHubService.verify() exception:{}'.format(e)) + self.result = {'success': False, 'error': str(e)} + + def search_issues(self): + """Search GitHub issues in the configured repository.""" + logger.debug('GitHubService.search_issues() repo:{} query:{}'.format( + self.repo, self.search_query + )) + try: + params = { + 'q': '{} repo:{} is:issue'.format( + self.search_query or '', self.repo or '' + ), + 'per_page': 50, + 'sort': 'updated', + 'order': 'desc' + } + resp = requests.get( + '{}/search/issues'.format(self.BASE_URL), + params=params, + auth=self._get_auth(), + headers=self._get_headers(), + timeout=15 + ) + resp.raise_for_status() + data = resp.json() + issues = data.get('items', []) + self.result = {} + for idx, issue in enumerate(issues): + assignee = issue.get('assignee') + self.result[idx] = { + 'issue_number': str(issue.get('number', '')), + 'number': '#{}'.format(issue.get('number', '')), + 'title': issue.get('title', ''), + 'state': issue.get('state', ''), + 'created_at': str(issue.get('created_at', ''))[:10], + 'assignee': assignee.get('login', '') if assignee else '', + 'html_url': issue.get('html_url', ''), + 'body': issue.get('body', '') or '' + } + except requests.exceptions.RequestException as e: + logger.error('GitHubService.search_issues() exception:{}'.format(e)) + self.result = {'error': True, 'error_message': str(e)} + + def get_issue_details(self): + """Fetch detailed data for a specific GitHub issue.""" + logger.debug('GitHubService.get_issue_details() repo:{} issue:{}'.format( + self.repo, self.issue_number + )) + try: + resp = requests.get( + '{}/repos/{}/issues/{}'.format( + self.BASE_URL, self.repo, self.issue_number + ), + auth=self._get_auth(), + headers=self._get_headers(), + timeout=10 + ) + resp.raise_for_status() + issue = resp.json() + assignee = issue.get('assignee') + body = issue.get('body', '') or '' + self.result = { + 0: { + 'number': str(issue.get('number', '')), + 'title': issue.get('title', ''), + 'state': issue.get('state', ''), + 'html_url': issue.get('html_url', ''), + 'body': body, + 'assignee': assignee.get('login', '') if assignee else '', + 'labels': ', '.join( + lbl.get('name', '') for lbl in issue.get('labels', []) + ), + 'task_title': issue.get('title', ''), + 'task_description': body, + 'stackfield_room_id': '', + 'task_priority': 'medium' + } + } + except requests.exceptions.RequestException as e: + logger.error('GitHubService.get_issue_details() exception:{}'.format(e)) + self.result = {'error': True, 'error_message': str(e)} + + +class StackfieldService(microesb.ClassHandler): + """Stackfield API service handler. + + Provides methods to interact with the Stackfield REST API for + credential verification and task creation. + + Stackfield API reference: https://stackfield.com/rest-api + """ + + BASE_URL = 'https://www.stackfield.com/api' + + def __init__(self): + super().__init__() + self.stackfield_email = None + self.stackfield_token = None + self.room_id = None + self.task_title = None + self.task_description = None + self.task_priority = None + self.github_issue_number = None + self.github_issue_url = None + self.result = {} + + def _get_headers(self): + return { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {}'.format(self.stackfield_token) + } + + def verify(self): + """Verify Stackfield credentials by fetching user profile.""" + logger.debug('StackfieldService.verify() email:{}'.format(self.stackfield_email)) + try: + resp = requests.get( + '{}/v1/user'.format(self.BASE_URL), + headers=self._get_headers(), + timeout=10 + ) + if resp.status_code == 200: + data = resp.json() + self.result = { + 'success': True, + 'user_id': str(data.get('id', '')), + 'name': data.get('name', ''), + 'email': data.get('email', '') + } + else: + self.result = { + 'success': False, + 'error': 'Authentication failed (HTTP {})'.format(resp.status_code) + } + except requests.exceptions.RequestException as e: + logger.error('StackfieldService.verify() exception:{}'.format(e)) + self.result = {'success': False, 'error': str(e)} + + def create_task(self): + """Create a new task in a Stackfield room from GitHub issue data.""" + logger.debug('StackfieldService.create_task() room:{} title:{}'.format( + self.room_id, self.task_title + )) + try: + priority_map = { + 'low': 1, + 'medium': 2, + 'high': 3, + 'urgent': 4 + } + priority_value = priority_map.get(self.task_priority or 'medium', 2) + + description = self.task_description or '' + if self.github_issue_url: + description = '{}\n\n---\nGitHub Issue: {}'.format( + description, self.github_issue_url + ) + + payload = { + 'title': self.task_title, + 'content': description, + 'priority': priority_value + } + + resp = requests.post( + '{}/v1/rooms/{}/tasks'.format(self.BASE_URL, self.room_id), + headers=self._get_headers(), + json=payload, + timeout=15 + ) + resp.raise_for_status() + data = resp.json() + self.result = { + 'success': True, + 'task_id': str(data.get('id', '')), + 'task_url': data.get('url', '') + } + except requests.exceptions.RequestException as e: + logger.error('StackfieldService.create_task() exception:{}'.format(e)) + self.result = {'success': False, 'error': str(e)} diff --git a/python/user_routing.py b/python/user_routing.py new file mode 100644 index 0000000..b593a02 --- /dev/null +++ b/python/user_routing.py @@ -0,0 +1,215 @@ +# ]*[ --------------------------------------------------------------------- ]*[ +# . github2stackfield - python-micro-esb Service Router . +# ]*[ --------------------------------------------------------------------- ]*[ +# . . +# . Defines routing functions called by ServiceRouter.send(). . +# . Each function receives a metadata dict from the x0 frontend and . +# . returns a result dict. . +# . . +# ]*[ --------------------------------------------------------------------- ]*[ + +import logging +import psycopg2 + +import DB +from pgdbpool import pool + +from service_implementation import GitHubService, StackfieldService + +logger = logging.getLogger(__name__) + +pool.Connection.init(DB.config) + +# --------------------------------------------------------------------------- +# Credential helpers +# --------------------------------------------------------------------------- + +def _load_credentials(dbcon, cred_type): + """Load stored API credentials from database.""" + try: + with dbcon.cursor() as crs: + crs.execute( + """ + SELECT username_or_email, api_token + FROM github2sf.credentials + WHERE credential_type = %s + """, + (cred_type,) + ) + row = crs.fetchone() + if row: + return {'username_or_email': row[0], 'api_token': row[1]} + except Exception as e: + logger.error('_load_credentials() error:{}'.format(e)) + return {} + + +def _store_credentials(dbcon, cred_type, username_or_email, api_token): + """Persist API credentials to the database.""" + try: + with dbcon.cursor() as crs: + crs.execute( + """ + INSERT INTO github2sf.credentials + (credential_type, username_or_email, api_token) + VALUES (%s, %s, %s) + ON CONFLICT (credential_type) DO UPDATE + SET username_or_email = EXCLUDED.username_or_email, + api_token = EXCLUDED.api_token, + updated_at = NOW() + """, + (cred_type, username_or_email, api_token) + ) + dbcon.commit() + except Exception as e: + logger.error('_store_credentials() error:{}'.format(e)) + dbcon.rollback() + + +# --------------------------------------------------------------------------- +# Routing functions +# --------------------------------------------------------------------------- + +def verify_github(metadata): + """Verify GitHub API credentials and persist them on success. + + :param dict metadata: expects keys GitHubUserInput, GitHubTokenInput + :return: result dict with success flag + """ + logger.debug('user_routing.verify_github() metadata:{}'.format(metadata)) + + svc = GitHubService() + svc.github_user = metadata.get('GitHubUserInput', '') + svc.github_token = metadata.get('GitHubTokenInput', '') + svc.verify() + + if svc.result.get('success'): + with pool.Handler('x0') as db: + _store_credentials( + db.connection, + 'github', + svc.github_user, + svc.github_token + ) + + return svc.result + + +def verify_stackfield(metadata): + """Verify Stackfield API credentials and persist them on success. + + :param dict metadata: expects keys StackfieldEmailInput, StackfieldTokenInput + :return: result dict with success flag + """ + logger.debug('user_routing.verify_stackfield() metadata:{}'.format(metadata)) + + svc = StackfieldService() + svc.stackfield_email = metadata.get('StackfieldEmailInput', '') + svc.stackfield_token = metadata.get('StackfieldTokenInput', '') + svc.verify() + + if svc.result.get('success'): + with pool.Handler('x0') as db: + _store_credentials( + db.connection, + 'stackfield', + svc.stackfield_email, + svc.stackfield_token + ) + + return svc.result + + +def search_github_issues(metadata): + """Search GitHub issues using stored credentials. + + :param dict metadata: expects keys SearchQueryInput, SearchRepoInput + :return: indexed dict of issue rows for x0 List population + """ + logger.debug('user_routing.search_github_issues() metadata:{}'.format(metadata)) + + with pool.Handler('x0') as db: + creds = _load_credentials(db.connection, 'github') + + if not creds: + return {'error': True, 'error_message': 'GitHub credentials not configured.'} + + svc = GitHubService() + svc.github_user = creds['username_or_email'] + svc.github_token = creds['api_token'] + svc.repo = metadata.get('SearchRepoInput', '') + svc.search_query = metadata.get('SearchQueryInput', '') + svc.search_issues() + + return svc.result + + +def get_github_issue_details(metadata): + """Fetch detailed GitHub issue data for Screen 3 population. + + :param dict metadata: expects key issue_number; repo loaded from DB + :return: single-row dict for x0 FormfieldList population + """ + logger.debug('user_routing.get_github_issue_details() metadata:{}'.format(metadata)) + + with pool.Handler('x0') as db: + creds = _load_credentials(db.connection, 'github') + + if not creds: + return {0: {'error': 'GitHub credentials not configured.'}} + + # Repo may be stored as a mapping state or passed via global var. + # Fall back to a stored repo config entry. + repo = metadata.get('repo', '') + if not repo: + with pool.Handler('x0') as db: + try: + with db.connection.cursor() as crs: + crs.execute( + """ + SELECT value FROM github2sf.app_config + WHERE config_key = 'last_search_repo' + """ + ) + row = crs.fetchone() + if row: + repo = row[0] + except Exception: + pass + + svc = GitHubService() + svc.github_user = creds['username_or_email'] + svc.github_token = creds['api_token'] + svc.repo = repo + svc.issue_number = metadata.get('issue_number', '') + svc.get_issue_details() + + return svc.result + + +def create_stackfield_task(metadata): + """Create a new Stackfield task from GitHub issue data. + + :param dict metadata: expects task fields and Stackfield room ID + :return: result dict with success flag and new task URL + """ + logger.debug('user_routing.create_stackfield_task() metadata:{}'.format(metadata)) + + with pool.Handler('x0') as db: + creds = _load_credentials(db.connection, 'stackfield') + + if not creds: + return {'success': False, 'error': 'Stackfield credentials not configured.'} + + svc = StackfieldService() + svc.stackfield_email = creds['username_or_email'] + svc.stackfield_token = creds['api_token'] + svc.room_id = metadata.get('StackfieldRoomInput', '') + svc.task_title = metadata.get('TaskTitleInput', '') + svc.task_description = metadata.get('TaskDescInput', '') + svc.task_priority = metadata.get('TaskPriorityPulldown', 'medium') + svc.github_issue_number = metadata.get('number', '') + svc.github_issue_url = metadata.get('html_url', '') + svc.create_task() + + return svc.result diff --git a/static/globalstyles.css b/static/globalstyles.css new file mode 100644 index 0000000..c53028b --- /dev/null +++ b/static/globalstyles.css @@ -0,0 +1,179 @@ +body { + font-family: Georgia, serif; + background-color: #2b5575; +} + +a:link { + color: #2b5550; +} + +a:visited { + color: #2b55a5; +} + +a:hover { + color: hotpink; +} + +a:active { + color: blue; +} + +.required::before { + content: "*"; + color: red; +} + +.overlay-default { + position: absolute; + top: 5%; + left: 5%; + width: 90%; + height: 50%; +} + +.menu-absolute-pos { + position: absolute; + top: 10px; + left: 10px; +} + +.th-height-default { + height:30px; +} + +.tr-height-default { + height:40px; +} + +.sysContextMenuTable { + position: absolute; + width: 200px; +} + +.sysTreeNodeRoot { +} + +.sysTreeSelected { + width: 4px; + height: 24px; + border-radius: 25px; + margin-right: 2px; + float: left; +} + +.sysTreeSelectedHilite { + background: #202020; +} + +.sysTreeItemContainer { + width: 150px; + height: 24px; + border-radius: 5px; + border-style: dotted; + border-width: thin; + border-color: #202020; + padding-left: 2px; + background: #848484; + float: right; +} + +.sysTreeIcon { + float: left; +} + +.sysTreeNodeText { + font-size: 14px; + padding-top: 0px; + padding-left: 2px; + float: left; +} + +.sysTreeNodeTextHilite { + border-style: solid; + background: #202020; + color: #ffffff; +} + +.sysTreeOpenClose { + padding-right: 2px; + float: right; +} + +.sysTreeItemLink { + font-size: 14px; + text-decoration: underline dotted; + padding-top: 0px; + padding-left: 2px; + float: left; +} + +.sysTreeItemIndent0 { + padding-left: 4px; +} + +.sysTreeItemIndent1 { + padding-left: 10px; +} + +.sysTreeItemIndent2 { + padding-left: 16px; +} + +.sysTreeItemIndent3 { + padding-left: 22px; +} + +.TreeOpenCloseContainerLeft { + width: 160px; + float: left; +} + +.TreeOpenCloseContainerRight { + width: 160px; + float: right; +} + +/* Notification indicator: success (green with green check) */ +.IndicatorSuccess { + background-color: #d1e7dd !important; + border-color: #a3cfbb !important; + color: #0f5132 !important; +} + +.IndicatorSuccess .col-2 > div { + color: #198754; + font-size: 1.4rem; +} + +/* Notification indicator: warning/error (red with circled exclamation) */ +.IndicatorWarning { + background-color: #f8d7da !important; + border-color: #f1aeb5 !important; + color: #58151c !important; +} + +.IndicatorWarning .col-2 > div { + width: 1.8rem; + height: 1.8rem; + border-radius: 50%; + border: 2px solid #dc3545; + display: flex; + align-items: center; + justify-content: center; + color: #dc3545; + font-size: 0.75rem; + flex-shrink: 0; +} + +/* Notification indicator: system error (orange/amber with bug icon) */ +.IndicatorError { + background-color: #fff3cd !important; + border-color: #ffe69c !important; + color: #664d03 !important; +} + +.IndicatorError .col-2 > div { + color: #fd7e14; + font-size: 1.4rem; +} diff --git a/static/menu.json b/static/menu.json new file mode 100644 index 0000000..dc304d5 --- /dev/null +++ b/static/menu.json @@ -0,0 +1,20 @@ +[ + { + "MenuLink1": + { + "RefID": "sysMenu" + } + }, + { + "MenuLink2": + { + "RefID": "sysMenu" + } + }, + { + "MenuLink3": + { + "RefID": "sysMenu" + } + } +] diff --git a/static/object.json b/static/object.json new file mode 100644 index 0000000..2ec2c91 --- /dev/null +++ b/static/object.json @@ -0,0 +1,771 @@ +{ + "MenuLink1": + { + "Type": "Link", + "Attributes": + { + "Style": "btn btn-outline-secondary d-block mb-2", + "TextID": "TXT.MENU.SCREEN1", + "ScreenID": "Screen1" + } + }, + + "MenuLink2": + { + "Type": "Link", + "Attributes": + { + "Style": "btn btn-outline-secondary d-block mb-2", + "TextID": "TXT.MENU.SCREEN2", + "ScreenID": "Screen2" + } + }, + + "MenuLink3": + { + "Type": "Link", + "Attributes": + { + "Style": "btn btn-outline-secondary d-block mb-2", + "TextID": "TXT.MENU.SCREEN3", + "ScreenID": "Screen3" + } + }, + + "GitHubCredentialsForm": + { + "Type": "FormfieldList", + "Attributes": + { + "Sections": + [ + { + "ID": "GitHubSection", + "Object": "FormSectionHeader", + "ObjectAttributes": + { + "Style": "card card-line bg-body-tertiary border border-dark border-2 mb-4", + "SubStyle": "card-body mt-n6", + "HeaderIcon": "fa-brands fa-github", + "HeaderTextID": "TXT.SCREEN1.GITHUB.SECTION.HEADER", + "SubHeaderTextID": "TXT.SCREEN1.GITHUB.SECTION.SUBHEADER" + }, + "Formfields": + [ + "GitHubUserLabel", + "GitHubUserInput", + "GitHubTokenLabel", + "GitHubTokenInput" + ], + "RowStyle": "row mb-3", + "RowAfterElements": 4, + "ColStyle": "col-md-6", + "ColAfterElements": 2 + } + ] + } + }, + + "GitHubUserLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN1.GITHUB.USER.LABEL" + } + }, + + "GitHubUserInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "Style": "form-control form-control-lg", + "Placeholder": "github-username", + "ValidateRef": "DefaultString", + "ValidateNullable": false + } + }, + + "GitHubTokenLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN1.GITHUB.TOKEN.LABEL" + } + }, + + "GitHubTokenInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "password", + "Style": "form-control form-control-lg", + "Placeholder": "ghp_xxxxxxxxxxxxxxxxxxxx", + "ValidateRef": "DefaultString", + "ValidateNullable": false + } + }, + + "GitHubVerifyButton": + { + "Type": "Button", + "Attributes": + { + "TextID": "TXT.SCREEN1.GITHUB.VERIFY.BUTTON", + "Style": "btn btn-outline-primary w-100 mb-4", + "IconStyle": "fa-solid fa-shield-halved", + "OnClick": "/python/github2sf/VerifyGitHubCredentials.py", + "SrcDataObjects": + [ + "GitHubCredentialsForm" + ], + "Notify": + { + "ID": "VerifyGitHubCredentials", + "DisplayHeaderID": "TXT.SCREEN1.GITHUB.VERIFY.NOTIFY" + } + } + }, + + "StackfieldCredentialsForm": + { + "Type": "FormfieldList", + "Attributes": + { + "Sections": + [ + { + "ID": "StackfieldSection", + "Object": "FormSectionHeader", + "ObjectAttributes": + { + "Style": "card card-line bg-body-tertiary border border-dark border-2 mb-4", + "SubStyle": "card-body mt-n6", + "HeaderIcon": "fa-solid fa-layer-group", + "HeaderTextID": "TXT.SCREEN1.STACKFIELD.SECTION.HEADER", + "SubHeaderTextID": "TXT.SCREEN1.STACKFIELD.SECTION.SUBHEADER" + }, + "Formfields": + [ + "StackfieldEmailLabel", + "StackfieldEmailInput", + "StackfieldTokenLabel", + "StackfieldTokenInput" + ], + "RowStyle": "row mb-3", + "RowAfterElements": 4, + "ColStyle": "col-md-6", + "ColAfterElements": 2 + } + ] + } + }, + + "StackfieldEmailLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN1.STACKFIELD.EMAIL.LABEL" + } + }, + + "StackfieldEmailInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "Style": "form-control form-control-lg", + "Placeholder": "user@example.com", + "ValidateRef": "DefaultString", + "ValidateNullable": false + } + }, + + "StackfieldTokenLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN1.STACKFIELD.TOKEN.LABEL" + } + }, + + "StackfieldTokenInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "password", + "Style": "form-control form-control-lg", + "Placeholder": "stackfield-api-token", + "ValidateRef": "DefaultString", + "ValidateNullable": false + } + }, + + "StackfieldVerifyButton": + { + "Type": "Button", + "Attributes": + { + "TextID": "TXT.SCREEN1.STACKFIELD.VERIFY.BUTTON", + "Style": "btn btn-outline-success w-100 mb-4", + "IconStyle": "fa-solid fa-shield-halved", + "OnClick": "/python/github2sf/VerifyStackfieldCredentials.py", + "SrcDataObjects": + [ + "StackfieldCredentialsForm" + ], + "Notify": + { + "ID": "VerifyStackfieldCredentials", + "DisplayHeaderID": "TXT.SCREEN1.STACKFIELD.VERIFY.NOTIFY" + } + } + }, + + "SearchForm": + { + "Type": "FormfieldList", + "Attributes": + { + "Sections": + [ + { + "ID": "SearchSection", + "Object": "FormSectionHeader", + "ObjectAttributes": + { + "Style": "card card-line bg-body-tertiary border border-dark border-2 mb-3", + "SubStyle": "card-body mt-n6", + "HeaderIcon": "fa-solid fa-magnifying-glass", + "HeaderTextID": "TXT.SCREEN2.SEARCH.SECTION.HEADER", + "SubHeaderTextID": "TXT.SCREEN2.SEARCH.SECTION.SUBHEADER" + }, + "Formfields": + [ + "SearchQueryLabel", + "SearchQueryInput", + "SearchRepoLabel", + "SearchRepoInput" + ], + "RowStyle": "row mb-3", + "RowAfterElements": 4, + "ColStyle": "col-md-6", + "ColAfterElements": 2 + } + ] + } + }, + + "SearchQueryLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN2.SEARCH.QUERY.LABEL" + } + }, + + "SearchQueryInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "Style": "form-control form-control-lg", + "Placeholder": "Search GitHub issues...", + "ValidateRef": "DefaultString", + "ValidateNullable": true + } + }, + + "SearchRepoLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN2.SEARCH.REPO.LABEL" + } + }, + + "SearchRepoInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "Style": "form-control form-control-lg", + "Placeholder": "owner/repository", + "ValidateRef": "DefaultString", + "ValidateNullable": false + } + }, + + "SearchButton": + { + "Type": "Button", + "Attributes": + { + "TextID": "TXT.SCREEN2.SEARCH.BUTTON", + "Style": "btn btn-primary w-100 mb-4", + "IconStyle": "fa-solid fa-magnifying-glass", + "FireEvents": [ "SearchGitHubIssues" ] + } + }, + + "IssueSearchConnector": + { + "Type": "ServiceConnector", + "Attributes": + { + "OnEvent": + { + "Events": [ "SearchGitHubIssues" ], + "ServiceCall": "/python/github2sf/SearchGitHubIssues.py" + }, + "SrcDataObjects": + { + "SearchForm": + { + "Type": "FormfieldList" + } + } + } + }, + + "IssueList": + { + "Type": "List", + "Attributes": + { + "Style": "border rounded-top rounded-bottom", + "HeaderRowStyle": "row fw-bold fs-6 m-2 border-bottom bg-light", + "RowCount": 15, + "Navigation": true, + "Columns": + [ + { + "ID": "issue_number", + "VisibleState": "hidden" + }, + { + "ID": "number", + "HeaderTextID": "TXT.SCREEN2.LIST.COL.NUMBER", + "HeaderStyle": "col-md-1 p-3 border-end text-center" + }, + { + "ID": "title", + "HeaderTextID": "TXT.SCREEN2.LIST.COL.TITLE", + "HeaderStyle": "col-md-5 p-3 border-end" + }, + { + "ID": "state", + "HeaderTextID": "TXT.SCREEN2.LIST.COL.STATE", + "HeaderStyle": "col-md-2 p-3 border-end text-center" + }, + { + "ID": "created_at", + "HeaderTextID": "TXT.SCREEN2.LIST.COL.CREATED", + "HeaderStyle": "col-md-2 p-3 border-end" + }, + { + "ID": "assignee", + "HeaderTextID": "TXT.SCREEN2.LIST.COL.ASSIGNEE", + "HeaderStyle": "col-md-2 p-3" + } + ], + "ContextMenuItems": + [ + { + "ID": "ConnectStackfieldTask", + "TextID": "TXT.SCREEN2.CONTEXTMENU.CONNECT", + "IconStyle": "fa-solid fa-link", + "DstScreenID": "Screen3", + "RowColumn": "issue_number", + "FireEvents": [ "ConnectStackfieldTask" ] + } + ], + "RowStyle": "row m-2 border-bottom", + "RowAfterElements": 6, + "ColStyle": + [ + "dummy", + "col-md-1 p-3 border-end text-center fw-bold text-primary", + "col-md-5 p-3 border-end", + "col-md-2 p-3 border-end text-center", + "col-md-2 p-3 border-end text-muted small", + "col-md-2 p-3 text-muted small" + ] + } + }, + + "IssueDetailsConnector": + { + "Type": "ServiceConnector", + "Attributes": + { + "OnEvent": + { + "Events": [ "ConnectStackfieldTask" ], + "ServiceCall": "/python/github2sf/GetGitHubIssueDetails.py" + }, + "SrcDataObjects": + { + "issue_number": + { + "Type": "ScreenGlobalVar" + } + } + } + }, + + "IssueDetailsForm": + { + "Type": "FormfieldList", + "Attributes": + { + "Sections": + [ + { + "ID": "IssueDetailsSection", + "Object": "FormSectionHeader", + "ObjectAttributes": + { + "Style": "card card-line bg-body-tertiary border border-dark border-2 mb-4", + "SubStyle": "card-body mt-n6", + "HeaderIcon": "fa-brands fa-github", + "HeaderTextID": "TXT.SCREEN3.ISSUE.SECTION.HEADER", + "SubHeaderTextID": "TXT.SCREEN3.ISSUE.SECTION.SUBHEADER" + }, + "Formfields": + [ + "IssueNumberLabel", + "IssueNumberField", + "IssueStateLabel", + "IssueStateField", + "IssueTitleLabel", + "IssueTitleField", + "IssueUrlLabel", + "IssueUrlField" + ], + "RowStyle": + [ + "row mb-3", + "row mb-3" + ], + "RowAfterElements": [ 4, 4 ], + "ColStyle": + [ + "col-md-3 mb-2", + "col-md-3 mb-2", + "col-md-6 mb-2", + "col-md-6 mb-2" + ] + } + ], + "SrcDataObjects": + { + "issue_number": + { + "Type": "ScreenGlobalVar" + } + } + } + }, + + "IssueNumberLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.ISSUE.NUMBER.LABEL" + } + }, + + "IssueNumberField": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "DBColumn": "number", + "Style": "form-control", + "Disabled": true + } + }, + + "IssueStateLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.ISSUE.STATE.LABEL" + } + }, + + "IssueStateField": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "DBColumn": "state", + "Style": "form-control", + "Disabled": true + } + }, + + "IssueTitleLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.ISSUE.TITLE.LABEL" + } + }, + + "IssueTitleField": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "DBColumn": "title", + "Style": "form-control", + "Disabled": true + } + }, + + "IssueUrlLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.ISSUE.URL.LABEL" + } + }, + + "IssueUrlField": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "DBColumn": "html_url", + "Style": "form-control", + "Disabled": true + } + }, + + "StackfieldMappingForm": + { + "Type": "FormfieldList", + "Attributes": + { + "Sections": + [ + { + "ID": "StackfieldMappingSection", + "Object": "FormSectionHeader", + "ObjectAttributes": + { + "Style": "card card-line bg-body-tertiary border border-dark border-2 mb-4", + "SubStyle": "card-body mt-n6", + "HeaderIcon": "fa-solid fa-layer-group", + "HeaderTextID": "TXT.SCREEN3.STACKFIELD.SECTION.HEADER", + "SubHeaderTextID": "TXT.SCREEN3.STACKFIELD.SECTION.SUBHEADER" + }, + "Formfields": + [ + "StackfieldRoomLabel", + "StackfieldRoomInput", + "TaskTitleLabel", + "TaskTitleInput", + "TaskDescLabel", + "TaskDescInput", + "TaskPriorityLabel", + "TaskPriorityPulldown" + ], + "RowStyle": + [ + "row mb-3", + "row mb-3", + "row mb-3" + ], + "RowAfterElements": [ 4, 2, 2 ], + "ColStyle": + [ + "col-md-6 mb-2", + "col-md-6 mb-2", + "col-md-12 mb-2", + "col-md-12 mb-2" + ] + } + ] + } + }, + + "StackfieldRoomLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.STACKFIELD.ROOM.LABEL" + } + }, + + "StackfieldRoomInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "DBColumn": "stackfield_room_id", + "Style": "form-control", + "Placeholder": "Stackfield Room ID", + "ValidateRef": "DefaultString", + "ValidateNullable": false + } + }, + + "TaskTitleLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.TASK.TITLE.LABEL" + } + }, + + "TaskTitleInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "text", + "DBColumn": "task_title", + "Style": "form-control", + "ValidateRef": "DefaultString", + "ValidateNullable": false + } + }, + + "TaskDescLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.TASK.DESC.LABEL" + } + }, + + "TaskDescInput": + { + "Type": "Formfield", + "Attributes": + { + "Type": "textarea", + "DBColumn": "task_description", + "Style": "form-control", + "Rows": 5, + "ValidateNullable": true + } + }, + + "TaskPriorityLabel": + { + "Type": "Formfield", + "Attributes": + { + "Type": "label", + "Style": "form-label fw-semibold", + "TextID": "TXT.SCREEN3.TASK.PRIORITY.LABEL" + } + }, + + "TaskPriorityPulldown": + { + "Type": "Formfield", + "Attributes": + { + "Type": "pulldown", + "DBColumn": "task_priority", + "Style": "form-select", + "Options": + [ + { + "TextID": "TXT.SCREEN3.PRIORITY.LOW", + "Value": "low", + "Default": true + }, + { + "TextID": "TXT.SCREEN3.PRIORITY.MEDIUM", + "Value": "medium" + }, + { + "TextID": "TXT.SCREEN3.PRIORITY.HIGH", + "Value": "high" + }, + { + "TextID": "TXT.SCREEN3.PRIORITY.URGENT", + "Value": "urgent" + } + ] + } + }, + + "CreateStackfieldTaskButton": + { + "Type": "Button", + "Attributes": + { + "TextID": "TXT.SCREEN3.CREATE.BUTTON", + "Style": "btn btn-success w-100 mt-2 mb-4", + "IconStyle": "fa-solid fa-circle-plus", + "OnClick": "/python/github2sf/CreateStackfieldTask.py", + "SrcDataObjects": + [ + "IssueDetailsForm", + "StackfieldMappingForm" + ], + "Notify": + { + "ID": "CreateStackfieldTask", + "DisplayHeaderID": "TXT.SCREEN3.CREATE.NOTIFY" + } + } + } +} diff --git a/static/skeleton.json b/static/skeleton.json new file mode 100644 index 0000000..060be60 --- /dev/null +++ b/static/skeleton.json @@ -0,0 +1,85 @@ +{ + "Screen1": + [ + { + "GitHubCredentialsForm": + { + "RefID": "Screen1" + } + }, + { + "GitHubVerifyButton": + { + "RefID": "Screen1" + } + }, + { + "StackfieldCredentialsForm": + { + "RefID": "Screen1" + } + }, + { + "StackfieldVerifyButton": + { + "RefID": "Screen1" + } + } + ], + + "Screen2": + [ + { + "SearchForm": + { + "RefID": "Screen2" + } + }, + { + "SearchButton": + { + "RefID": "Screen2" + } + }, + { + "IssueSearchConnector": + { + "RefID": "Screen2" + } + }, + { + "IssueList": + { + "RefID": "IssueSearchConnector" + } + } + ], + + "Screen3": + [ + { + "IssueDetailsConnector": + { + "RefID": "Screen3" + } + }, + { + "IssueDetailsForm": + { + "RefID": "IssueDetailsConnector" + } + }, + { + "StackfieldMappingForm": + { + "RefID": "IssueDetailsConnector" + } + }, + { + "CreateStackfieldTaskButton": + { + "RefID": "Screen3" + } + } + ] +}