Compare commits
29 Commits
9aec267e0a
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
ad4e58926e
|
|||
|
056c885e8b
|
|||
|
b0f2a1452c
|
|||
|
af8983ecdd
|
|||
|
5084f5b10d
|
|||
|
55fb234db6
|
|||
|
d8db96603b
|
|||
|
c45e12ed9a
|
|||
|
d830f33eec
|
|||
| 825623c9f3 | |||
|
81350f38e4
|
|||
|
1f2d42e1d5
|
|||
|
a0f67464b5
|
|||
|
ff5699e627
|
|||
|
dc0040c2d1
|
|||
|
9950282d6e
|
|||
|
7c8e8eb236
|
|||
|
442b5d25b4
|
|||
|
4763d48290
|
|||
|
1af6596b51
|
|||
|
c5f4f9fcf9
|
|||
|
9f544c66c2
|
|||
|
7c1cdb64ec
|
|||
|
4bb787488d
|
|||
|
ab0df54893
|
|||
|
7b6cc33ecb
|
|||
|
696fd15cfa
|
|||
|
957c5b91bc
|
|||
| 1d8a70768f |
@@ -1,4 +0,0 @@
|
||||
kind: Added
|
||||
body: Add an option `--assign-to-me` to automatically assign the openproject's user
|
||||
to the newly created work package
|
||||
time: 2024-03-17T21:35:42.724855046+01:00
|
||||
@@ -1,3 +0,0 @@
|
||||
kind: Added
|
||||
body: Allow to configure multiple gitlab instances
|
||||
time: 2024-03-17T21:35:56.447907065+01:00
|
||||
5
.changes/v0.2.0.md
Normal file
5
.changes/v0.2.0.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## v0.2.0 - 2024-11-14
|
||||
### Added
|
||||
* Add an option `--assign-to-me` to automatically assign the openproject's user to the newly created work package
|
||||
* Allow to configure multiple gitlab instances
|
||||
* Create work pakcage from gitea
|
||||
3
.changes/v0.3.0.md
Normal file
3
.changes/v0.3.0.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v0.3.0 - 2024-11-17
|
||||
### Added
|
||||
* Open the newly create work package after the wp has been created
|
||||
3
.changes/v0.4.0.md
Normal file
3
.changes/v0.4.0.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v0.4.0 - 2025-10-24
|
||||
### Added
|
||||
* [planning - i2work] Met à jour le body du ticket gitea avec un lien vers le work package, dans une section "related issues"
|
||||
3
.changes/v0.4.1.md
Normal file
3
.changes/v0.4.1.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v0.4.1 - 2025-10-25
|
||||
### Fixed
|
||||
* Fix dependencies
|
||||
@@ -24,3 +24,9 @@ newlines:
|
||||
beforeChangelogVersion: 1
|
||||
endOfVersion: 1
|
||||
envPrefix: CHANGIE_
|
||||
|
||||
# update the version in the cargo file, on release
|
||||
replacements:
|
||||
- path: Cargo.toml
|
||||
find: '^version = ".*"'
|
||||
replace: 'version = "{{.VersionNoPrefix}}"'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release binary for cl-cli
|
||||
name: Release binary and debian package for cl-cli
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -15,22 +15,47 @@ jobs:
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
- name: Build binaries
|
||||
run: cargo build --release
|
||||
- name: Install cargo-deb
|
||||
run: cargo install cargo-deb
|
||||
- name: Build Debian package
|
||||
run: cargo deb
|
||||
- name: Upload Debian package to Gitea registry
|
||||
env:
|
||||
GITEA_SERVER_URL: "https://gitea.champs-libres.be"
|
||||
GITEA_OWNER: ${{ secrets.GITEA_OWNER }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
DEB_DISTRIBUTIONS: noble,plucky,questing
|
||||
DEB_COMPONENT: main
|
||||
DEB_ARCH: amd64
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEB_FILE=$(ls target/debian/*.deb | head -n1)
|
||||
env
|
||||
if [ -z "${DEB_FILE}" ]; then
|
||||
echo "No .deb file found in target/debian" >&2
|
||||
exit 1
|
||||
fi
|
||||
# comma-separated in DEB_DISTRIBUTIONS, e.g. "noble,plucky,questing"
|
||||
IFS=',' read -r -a DISTROS <<< "$DEB_DISTRIBUTIONS"
|
||||
for DISTRO in "${DISTROS[@]}"; do
|
||||
echo "Uploading ${DEB_FILE} to ${GITEA_SERVER_URL} for owner ${GITEA_OWNER}"
|
||||
curl -sSf -X PUT \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
--upload-file @"${DEB_FILE}" \
|
||||
"${GITEA_SERVER_URL}/api/packages/${GITEA_OWNER}/debian/pool/${DISTRO}/main/upload"
|
||||
done
|
||||
- name: Read release content
|
||||
uses: https://github.com/jaywcjlove/github-action-read-file@main
|
||||
id: read_release
|
||||
with:
|
||||
path: .changes/${{ github.ref_name }}.md
|
||||
- name: Setup go for using go gitea actions
|
||||
uses: https://github.com/actions/setup-go@v4
|
||||
with:
|
||||
go-version: '>=1.20.1'
|
||||
- name: Use Go Action to release
|
||||
id: use-go-action
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
- name: Release
|
||||
uses: https://gitea.com/akkuman/gitea-release-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
|
||||
with:
|
||||
files: |-
|
||||
target/release/cl-cli
|
||||
config.toml.dist
|
||||
api_key: '${{secrets.RELEASE_TOKEN}}'
|
||||
body: |
|
||||
${{ steps.read_release.outputs.content }}
|
||||
md5sum: true
|
||||
sha256sum: true
|
||||
body_path: .changes/${{ github.ref_name }}.md
|
||||
|
||||
25
.gitea/workflows/release/check.yaml
Normal file
25
.gitea/workflows/release/check.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Check go code
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request_target:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
- name: Install rust toolchain
|
||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
||||
- name: Build binaries
|
||||
run: cargo build
|
||||
- name: Run tests
|
||||
run: cargo test
|
||||
130
.junie/guidelines.md
Normal file
130
.junie/guidelines.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# cl-cli — Project-specific development guidelines
|
||||
|
||||
This document captures project-specific knowledge to help advanced contributors work efficiently on this repository. It focuses on practical, non-obvious details (build, configuration, testing, and development tips) specific to this codebase.
|
||||
|
||||
## 1. Build and configuration
|
||||
|
||||
- Rust workspace/binaries
|
||||
- Single crate with two binaries defined in `Cargo.toml`:
|
||||
- `cl-cli` → `src/main.rs`: the CLI entrypoint.
|
||||
- `webext` → `src/webext.rs`: a Native Messaging host used by the browser extension.
|
||||
- Library module tree under `src/lib.rs` exposes common code to both binaries.
|
||||
- Toolchain: stable Rust; CI uses dtolnay/rust-toolchain@stable.
|
||||
- Dependencies and async runtime
|
||||
- Async via `tokio` (features: `rt`, `rt-multi-thread`, `macros`). Both binaries use `#[tokio::main]`.
|
||||
- HTTP via `reqwest`; serialization via `serde`/`serde_json`; configuration via `toml`.
|
||||
- Build
|
||||
- Standard build works without extra flags:
|
||||
- `cargo build` (debug) or `cargo build --release` (optimized).
|
||||
- Individual binaries:
|
||||
- `cargo build --bin cl-cli`
|
||||
- `cargo build --bin webext`
|
||||
- The web extension host (`webext`) is a regular Rust binary; it is not bundled by NPM tooling.
|
||||
- Configuration file
|
||||
- Expected location by default: `$HOME/.config/cl-cli/config.toml`.
|
||||
- The CLI allows overriding the path via `--config <PATH>`; the webext host always reads the default path.
|
||||
- Template: `config.toml.dist`. Minimal fields:
|
||||
- `[[gitlab]] { token, domain }`
|
||||
- `[[gitea]] { token, domain }`
|
||||
- `[openproject] { token, base_url }`
|
||||
- Example of initial setup (copy the dist and edit):
|
||||
```bash
|
||||
mkdir -p "$HOME/.config/cl-cli"
|
||||
cp config.toml.dist "$HOME/.config/cl-cli/config.toml"
|
||||
$EDITOR "$HOME/.config/cl-cli/config.toml"
|
||||
```
|
||||
- Error surface: parsing and IO errors are surfaced as `GeneralError`; the CLI will print and exit non‑zero, the `webext` host will return an error payload.
|
||||
- Local OpenProject for development
|
||||
- `docker-compose.yaml` provides a local OpenProject (13.x) at `http://localhost:8080` with persisted volumes under `.docker-data/`.
|
||||
|
||||
## 2. Testing
|
||||
|
||||
- Test types present
|
||||
- Unit tests live alongside code under `#[cfg(test)]` modules (e.g., `src/gitea/issue.rs`, `src/webext.rs`, `src/planning/utils.rs`).
|
||||
- Integration tests can be placed under `tests/`. The crate exposes internal modules via `src/lib.rs` for integration testing.
|
||||
- Running tests
|
||||
- `cargo test` runs unit and integration tests. CI also runs `cargo test` on every push/PR.
|
||||
- Tests are pure and do not require network or external services; avoid adding network‑bound tests.
|
||||
- Adding new tests
|
||||
- Prefer unit tests near the logic being tested. For integration coverage across modules, place files under `tests/` and use public items from `cl_cli`.
|
||||
- Keep tests deterministic: no real HTTP calls; if needed, mock boundaries at the client layer.
|
||||
- Example integration test (verified locally during preparation)
|
||||
- Minimal example using a pure helper. Create `tests/example_gitea_url.rs`:
|
||||
```rust
|
||||
use url::Url;
|
||||
|
||||
#[test]
|
||||
fn converts_gitea_issue_html_url_to_api_url() {
|
||||
// Pure transformation; no network
|
||||
let input = Url::parse("https://gitea.champs-libres.be/champs-libres/test/issues/1").unwrap();
|
||||
let out = cl_cli::gitea::issue::issue_html_url_to_api(&input).unwrap();
|
||||
assert_eq!(
|
||||
out.as_str(),
|
||||
"https://gitea.champs-libres.be/api/v1/repos/champs-libres/test/issues/1"
|
||||
);
|
||||
}
|
||||
```
|
||||
- Run it with `cargo test`. Remove the file if it was only created as a demo.
|
||||
|
||||
## 3. CLI usage tips (project‑specific)
|
||||
|
||||
- `cl-cli planning i2work <gitlab_issue_url> <openproject_project_identifier>` creates an OpenProject work package from a GitLab issue.
|
||||
- The OpenProject project identifier is the short slug in URLs, e.g. `chill` in `.../projects/chill/...`.
|
||||
- The command uses tokens from the configuration file to authenticate to GitLab and OpenProject.
|
||||
- Example:
|
||||
```bash
|
||||
cl-cli planning i2work \
|
||||
https://gitlab.com/Chill-Projet/chill-bundles/-/issues/240 \
|
||||
chill
|
||||
```
|
||||
- `cl-cli test` invokes a debug routine (see `src/debug.rs`) primarily for local diagnostics.
|
||||
|
||||
## 4. Web extension native host (`webext`)
|
||||
|
||||
- Purpose: acts as a Native Messaging host for a browser extension under `web-extension/`.
|
||||
- Protocol: communicates via stdin/stdout using 4‑byte length‑prefixed JSON messages.
|
||||
- Input `serde` enum is tagged with `type`; currently supports `{"type":"Issue2Work", ...}`.
|
||||
- Output is tagged with `result` (`Ok` or `Error`) and contains `data` when successful.
|
||||
- Business flow: the host reads config from `$HOME/.config/cl-cli/config.toml`, translates the input into `Issue2Work` CLI arguments, delegates to `planning::issue2work::handle_issue2work`, and returns the created work package URL.
|
||||
- Testing the transport: see unit tests in `src/webext.rs` that exercise message framing and deserialization.
|
||||
|
||||
## 5. Code style and architectural notes
|
||||
|
||||
- Module layout
|
||||
- Public library facade (`src/lib.rs`) re‑exports internal modules for reuse by both binaries and tests.
|
||||
- Planning features under `src/planning/` with a trait (`Issue2WorkActionTrait`) and concrete actions per platform.
|
||||
- Git providers (`src/gitlab`, `src/gitea`) and OpenProject client live in dedicated modules.
|
||||
- Error handling
|
||||
- Unified lightweight `GeneralError` encapsulates user‑facing messages; conversions from `reqwest::Error` and header errors are provided.
|
||||
- Data parsing
|
||||
- Input/Output types rely on `serde` with explicit tagging for clarity and forward compatibility.
|
||||
- Concurrency
|
||||
- All async entrypoints are `#[tokio::main]`; downstream async functions return `impl Future` to stay generic and testable.
|
||||
- External calls
|
||||
- Wrap external service interactions behind client modules; this eases mocking in tests and keeps pure helpers (like URL mappers) easily testable.
|
||||
|
||||
## 6. Continuous Integration
|
||||
|
||||
- `.gitea/workflows/release/check.yaml` performs:
|
||||
- Checkout → install stable toolchain → `cargo build` → `cargo test`.
|
||||
- Release job (see `.gitea/workflows/release/build-and-release.yaml`) builds release artifacts for distribution.
|
||||
|
||||
## 7. Troubleshooting / gotchas (project‑specific)
|
||||
|
||||
- Config path
|
||||
- The CLI accepts `--config`, but the webext host reads the default path only. Ensure `$HOME/.config/cl-cli/config.toml` exists when using the browser integration.
|
||||
- Tokens scope
|
||||
- Ensure personal access tokens for GitLab and OpenProject have sufficient scopes for reading issues and creating work packages respectively.
|
||||
- Locale/URLs
|
||||
- `Issue2Work` requires the OpenProject project identifier (slug), not its display name.
|
||||
- HTTP environment
|
||||
- `reqwest` honors proxy vars (`HTTP_PROXY`, `HTTPS_PROXY`) if present; be aware for corporate environments.
|
||||
|
||||
## 8. Commands quick reference
|
||||
|
||||
- Build: `cargo build --release`
|
||||
- Run CLI: `cargo run --bin cl-cli -- <args>`
|
||||
- Run webext host directly for dev: `cargo run --bin webext`
|
||||
- Tests: `cargo test`
|
||||
- Linting: `cargo clippy` (not enforced in CI but recommended)
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -6,6 +6,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
|
||||
## v0.4.1 - 2025-10-25
|
||||
### Fixed
|
||||
* Fix dependencies
|
||||
|
||||
## v0.4.0 - 2025-10-24
|
||||
### Added
|
||||
* [planning - i2work] Met à jour le body du ticket gitea avec un lien vers le work package, dans une section "related issues"
|
||||
|
||||
## v0.3.0 - 2024-11-17
|
||||
### Added
|
||||
* Open the newly create work package after the wp has been created
|
||||
|
||||
## v0.2.0 - 2024-11-14
|
||||
### Added
|
||||
* Add an option `--assign-to-me` to automatically assign the openproject's user to the newly created work package
|
||||
* Allow to configure multiple gitlab instances
|
||||
* Create work pakcage from gitea
|
||||
|
||||
## v0.1.0 - 2024-01-08
|
||||
### Added
|
||||
* Initiate changie versioning
|
||||
|
||||
2059
Cargo.lock
generated
2059
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
38
Cargo.toml
@@ -1,17 +1,39 @@
|
||||
[package]
|
||||
name = "cl-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[[bin]]
|
||||
name="cl-cli"
|
||||
path="src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name="webext"
|
||||
path="src/webext.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.1.13", features = ["derive"] }
|
||||
gitlab = "0.1607.0"
|
||||
reqwest = "0.11.23"
|
||||
serde = { version = "1.0.158", features = ["derive"] }
|
||||
toml = "0.7.3"
|
||||
url = "2.3.1"
|
||||
gitlab = "0.1804.0"
|
||||
reqwest = "0.12.24"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
toml = "0.9.8"
|
||||
url = "2.5.7"
|
||||
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
simple-home-dir = "0.2.1"
|
||||
log = "0.4.17"
|
||||
simple-home-dir = "0.5.2"
|
||||
log = "0.4.28"
|
||||
open = "5.3.2"
|
||||
serde_json = "1.0.145"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Julien Fastré <julien.fastre@champs-libres.coop>"
|
||||
copyright = "AGPLv3"
|
||||
extended-description = """Helpers for Champs-Libres"""
|
||||
depends = "$auto"
|
||||
section = "utility"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["target/release/cl-cli", "usr/bin/cl", "755"],
|
||||
["target/release/webext", "usr/bin/", "755"],
|
||||
]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
[gitlab]
|
||||
[[gitlab]]
|
||||
# generate from https://gitlab.com/-/user_settings/personal_access_tokens
|
||||
token = "glpat-example"
|
||||
domain = "gitlab.com"
|
||||
|
||||
[[gitea]]
|
||||
token = "abcdexempletoken"
|
||||
domain = "gitea.champs-libres.be"
|
||||
|
||||
[openproject]
|
||||
# generate api token from https://champs-libres.openproject.com/my/access_token
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
pub(crate) struct Cli {
|
||||
pub struct Cli {
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
@@ -12,19 +12,19 @@ pub(crate) struct Cli {
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Commands {
|
||||
pub enum Commands {
|
||||
#[command(subcommand)]
|
||||
Planning(Planning),
|
||||
Test,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Planning {
|
||||
pub enum Planning {
|
||||
I2work(Issue2Work),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub(crate) struct Issue2Work {
|
||||
pub struct Issue2Work {
|
||||
pub issue_url: String,
|
||||
pub project_id: String,
|
||||
#[arg(short, long)]
|
||||
|
||||
@@ -1,19 +1,59 @@
|
||||
use crate::error::GeneralError;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Config {
|
||||
pub struct Config {
|
||||
pub gitlab: Vec<GitlabConfig>,
|
||||
pub gitea: Vec<GiteaConfig>,
|
||||
pub openproject: OpenProjectConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct GitlabConfig {
|
||||
pub struct GitlabConfig {
|
||||
pub token: String,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct OpenProjectConfig {
|
||||
pub struct GiteaConfig {
|
||||
pub token: String,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct OpenProjectConfig {
|
||||
pub token: String,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
pub struct BuildConfigError {
|
||||
msg: String,
|
||||
}
|
||||
|
||||
impl Into<GeneralError> for BuildConfigError {
|
||||
fn into(self) -> GeneralError {
|
||||
GeneralError {
|
||||
description: format!("Could not build config: {}", self.msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_config(config_path: &Path) -> Result<Config, BuildConfigError> {
|
||||
let config_path_content = match fs::read_to_string(config_path) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
return Err(BuildConfigError {
|
||||
msg: format!(
|
||||
"Could not read a config file at {:?}, error: {}",
|
||||
config_path, e
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let config: Config = toml::from_str(&*config_path_content).expect("Could not parse config");
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
22
src/debug.rs
22
src/debug.rs
@@ -1,12 +1,24 @@
|
||||
use crate::config::Config;
|
||||
use crate::error::GeneralError;
|
||||
use crate::openproject::client::Client;
|
||||
use crate::openproject::root::RootClient;
|
||||
use crate::openproject::user::GetMe;
|
||||
use crate::gitea::issue::Issue;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
|
||||
pub async fn debug(config: Config) -> Result<(), GeneralError> {
|
||||
println!("test");
|
||||
|
||||
let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap());
|
||||
let issue: Issue = gitea_client
|
||||
.get(
|
||||
Url::parse("https://gitea.champs-libres.be/api/v1/repos/julienfastre/test/issues/6")
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("issue: {:?}", issue);
|
||||
|
||||
Ok(())
|
||||
|
||||
/*
|
||||
let open_project_client = Client::from_config(&config.openproject);
|
||||
println!("base_url: {}", open_project_client.base_url);
|
||||
println!("base_url: will get root");
|
||||
@@ -16,4 +28,6 @@ pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
|
||||
println!("me: {:?}", u);
|
||||
|
||||
Ok(())
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
21
src/error.rs
21
src/error.rs
@@ -1,4 +1,23 @@
|
||||
use reqwest::header::InvalidHeaderValue;
|
||||
use reqwest::Error;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GeneralError {
|
||||
pub(crate) description: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl From<InvalidHeaderValue> for GeneralError {
|
||||
fn from(_value: InvalidHeaderValue) -> Self {
|
||||
GeneralError {
|
||||
description: "Unable to convert the token into header value".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for GeneralError {
|
||||
fn from(value: Error) -> Self {
|
||||
GeneralError {
|
||||
description: format!("Unable to perform a request: {}", value.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
82
src/gitea/action.rs
Normal file
82
src/gitea/action.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use crate::cli::Issue2Work;
|
||||
use crate::config::Config;
|
||||
use crate::error::GeneralError;
|
||||
use crate::gitea::client::has_client_for_url;
|
||||
use crate::gitea::issue::{issue_html_url_to_api, Issue, IssueWriteSetBody};
|
||||
use crate::openproject::user::{GetMe, User};
|
||||
use crate::openproject::work::WorkPackageWriter;
|
||||
use crate::planning::utils::{append_related_issues, IssueRelated};
|
||||
use crate::planning::Issue2WorkActionTrait;
|
||||
use crate::planning::Issue2WorkResult;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) struct GiteaAction {}
|
||||
|
||||
impl Issue2WorkActionTrait for GiteaAction {
|
||||
async fn run(
|
||||
&self,
|
||||
url: &Url,
|
||||
config: &Config,
|
||||
args: &Issue2Work,
|
||||
) -> Result<Issue2WorkResult, GeneralError> {
|
||||
let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap());
|
||||
let issue: Issue = gitea_client.get(issue_html_url_to_api(url)?).await?;
|
||||
let open_project_client =
|
||||
crate::openproject::client::Client::from_config(&config.openproject);
|
||||
|
||||
let work_package = create_work_package_from_issue(
|
||||
&issue,
|
||||
match args.assign_to_me {
|
||||
true => {
|
||||
let u = open_project_client.me().await?;
|
||||
Some(u)
|
||||
}
|
||||
false => None,
|
||||
},
|
||||
);
|
||||
|
||||
let work_package = open_project_client
|
||||
.create_work_package(&work_package, &args.project_id)
|
||||
.await?;
|
||||
|
||||
let url_wp = format!(
|
||||
"{}/projects/{}/work_packages/{}",
|
||||
config.openproject.base_url, args.project_id, work_package.id
|
||||
);
|
||||
|
||||
let content = append_related_issues(
|
||||
&IssueRelated::OpenProjectIssue(url_wp.to_string()),
|
||||
&issue.body,
|
||||
);
|
||||
let _u: Issue = gitea_client
|
||||
.patch(
|
||||
issue_html_url_to_api(url)?,
|
||||
&IssueWriteSetBody { body: content },
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Issue2WorkResult {
|
||||
work_package_url: url_wp,
|
||||
subject: work_package.subject,
|
||||
})
|
||||
}
|
||||
|
||||
fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {
|
||||
has_client_for_url(&url, &config)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_work_package_from_issue(issue: &Issue, assignee: Option<User>) -> WorkPackageWriter {
|
||||
WorkPackageWriter {
|
||||
subject: format!(
|
||||
"{} ({}/{})",
|
||||
issue.title, issue.repository.full_name, issue.number
|
||||
),
|
||||
work_type: "TASK".into(),
|
||||
description: crate::openproject::work::DescriptionWriter {
|
||||
format: "markdown".into(),
|
||||
raw: format!("From Gitea issue: {} \n\n{}", issue.html_url, issue.body),
|
||||
},
|
||||
assignee: assignee.into(),
|
||||
}
|
||||
}
|
||||
144
src/gitea/client.rs
Normal file
144
src/gitea/client.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use crate::config::{Config, GiteaConfig};
|
||||
use crate::error::GeneralError;
|
||||
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
|
||||
use reqwest::{ClientBuilder, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
token: String,
|
||||
// base_uri: String,
|
||||
}
|
||||
|
||||
fn is_client_for_url(url: &Url, config: &GiteaConfig) -> bool {
|
||||
if url.domain() == Some(config.domain.as_str()) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn has_client_for_url(url: &Url, config: &Config) -> bool {
|
||||
for c in &config.gitea {
|
||||
if is_client_for_url(url, c) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn client_for_url(url: &Url, config: &Config) -> Result<Client, GeneralError> {
|
||||
for c in &config.gitea {
|
||||
if is_client_for_url(url, c) {
|
||||
return Ok(Client::from_config(&c));
|
||||
}
|
||||
}
|
||||
|
||||
Err(GeneralError {
|
||||
description: format!("No gitea client found for url {}", url),
|
||||
})
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn from_config(config: &GiteaConfig) -> Self {
|
||||
Self::new(&config.token, &config.domain)
|
||||
}
|
||||
|
||||
pub fn new(token: &String, _domain: &String) -> Self {
|
||||
Client {
|
||||
token: token.clone(),
|
||||
// base_uri: format!("https://{}", domain.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<T, GeneralError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(AUTHORIZATION, format!("token {}", self.token).parse()?);
|
||||
headers.append(ACCEPT, "application/json".parse()?);
|
||||
|
||||
let client = ClientBuilder::new()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let response = client.get(url.clone()).send().await?;
|
||||
|
||||
match response.status() {
|
||||
StatusCode::OK => {
|
||||
let result: T = response.json().await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
_ => Err(GeneralError {
|
||||
description: format!(
|
||||
"Could not call GET on {:?}, error code {}",
|
||||
url,
|
||||
response.status()
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn patch<T: DeserializeOwned, B: Serialize>(
|
||||
&self,
|
||||
url: Url,
|
||||
body: &B,
|
||||
) -> Result<T, GeneralError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(AUTHORIZATION, format!("token {}", self.token).parse()?);
|
||||
headers.append(ACCEPT, "application/json".parse()?);
|
||||
|
||||
let client = ClientBuilder::new()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let response = client.patch(url.clone()).json(body).send().await?;
|
||||
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::CREATED => {
|
||||
let result: T = response.json().await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
_ => Err(GeneralError {
|
||||
description: format!(
|
||||
"Could not call PATCH on {:?}, error code {}",
|
||||
url,
|
||||
response.status()
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_client_for_url() {
|
||||
let config = GiteaConfig {
|
||||
domain: "gitea.champs-libres.be".into(),
|
||||
token: "<PASSWORD>".into(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
is_client_for_url(
|
||||
&Url::parse("https://gitea.champs-libres.be/something/somewhere").unwrap(),
|
||||
&config
|
||||
),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
is_client_for_url(
|
||||
&Url::parse("https://somewhere.else/something/somewhere").unwrap(),
|
||||
&config
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/gitea/issue.rs
Normal file
64
src/gitea/issue.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use crate::error::GeneralError;
|
||||
use crate::gitea::repository::Repository;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Issue {
|
||||
// pub id: u64,
|
||||
pub number: u64,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub repository: Repository,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueWriteSetBody {
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> {
|
||||
let mut parts = url.path_segments().unwrap();
|
||||
let domain = parts.next().unwrap();
|
||||
let repo = parts.next().unwrap();
|
||||
let issue = parts.next().unwrap();
|
||||
let iid = parts.next().unwrap();
|
||||
|
||||
if !issue.eq("issues") {
|
||||
return Err(GeneralError {
|
||||
description: format!("Issue url is not valid: {}", url),
|
||||
});
|
||||
}
|
||||
|
||||
let url = Url::parse(
|
||||
format!(
|
||||
"{}://{}/api/v1/repos/{}/{}/issues/{}",
|
||||
url.scheme(),
|
||||
url.host().unwrap(),
|
||||
domain,
|
||||
repo,
|
||||
iid
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_issue_html_url_to_api() {
|
||||
let url = Url::parse("https://gitea.champs-libres.be/champs-libres/test/issues/1").unwrap();
|
||||
let result = issue_html_url_to_api(&url).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.as_str(),
|
||||
"https://gitea.champs-libres.be/api/v1/repos/champs-libres/test/issues/1"
|
||||
);
|
||||
}
|
||||
}
|
||||
4
src/gitea/mod.rs
Normal file
4
src/gitea/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod action;
|
||||
pub mod client;
|
||||
pub mod issue;
|
||||
pub mod repository;
|
||||
9
src/gitea/repository.rs
Normal file
9
src/gitea/repository.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Repository {
|
||||
// id: u64,
|
||||
// name: String,
|
||||
// owner: String,
|
||||
pub full_name: String,
|
||||
}
|
||||
147
src/gitlab/action.rs
Normal file
147
src/gitlab/action.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use crate::cli::Issue2Work;
|
||||
use crate::config::Config;
|
||||
use crate::error::GeneralError;
|
||||
use crate::gitlab::client::{client_for_url, has_client_for_url};
|
||||
use crate::gitlab::issue::{Issue, IssueBundle, Project};
|
||||
use crate::openproject::client::Client;
|
||||
use crate::openproject::user::{GetMe, User};
|
||||
use crate::openproject::work::WorkPackageWriterAssignee;
|
||||
use crate::planning::Issue2WorkActionTrait;
|
||||
use crate::planning::Issue2WorkResult;
|
||||
use gitlab::api::{issues, projects, AsyncQuery};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IssueInfo {
|
||||
project: String,
|
||||
iid: u64,
|
||||
}
|
||||
|
||||
/// details on how to create a work package from various informations
|
||||
#[derive(Debug)]
|
||||
struct Issue2WorkPackageDTO {
|
||||
pub issue: IssueBundle,
|
||||
pub assign_to: Option<User>,
|
||||
}
|
||||
|
||||
pub(crate) struct GitlabAction {}
|
||||
|
||||
impl Issue2WorkActionTrait for GitlabAction {
|
||||
async fn run(
|
||||
&self,
|
||||
url: &Url,
|
||||
config: &Config,
|
||||
args: &Issue2Work,
|
||||
) -> Result<Issue2WorkResult, GeneralError> {
|
||||
let client = client_for_url(&url, &config).await?;
|
||||
let data = extract_issue_info(&url).unwrap();
|
||||
|
||||
let endpoint = issues::ProjectIssues::builder()
|
||||
.iid(data.iid)
|
||||
.project(String::from(data.project))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let issues: Vec<Issue> = endpoint.query_async(&client).await.unwrap();
|
||||
let issue = issues.first().unwrap();
|
||||
|
||||
let project_endpoint = projects::Project::builder()
|
||||
.project(issue.project_id)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let project: Project = project_endpoint.query_async(&client).await.unwrap();
|
||||
|
||||
let issue_bundle = IssueBundle::new(&issue, &project);
|
||||
|
||||
let open_project_client = Client::from_config(&config.openproject);
|
||||
|
||||
let dto = Issue2WorkPackageDTO {
|
||||
issue: issue_bundle,
|
||||
assign_to: match args.assign_to_me {
|
||||
true => {
|
||||
let u = open_project_client.me().await?;
|
||||
Some(u)
|
||||
}
|
||||
false => None,
|
||||
},
|
||||
};
|
||||
|
||||
let work_package = open_project_client
|
||||
.create_work_package(&(&dto).into(), &args.project_id)
|
||||
.await?;
|
||||
|
||||
let result = Issue2WorkResult {
|
||||
work_package_url: format!(
|
||||
"{}/projects/{}/work_packages/{}",
|
||||
config.openproject.base_url, args.project_id, work_package.id
|
||||
),
|
||||
subject: work_package.subject,
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {
|
||||
has_client_for_url(&url, &config)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_issue_info(url: &Url) -> Option<IssueInfo> {
|
||||
let parts = url
|
||||
.path_segments()
|
||||
.expect("Could not parse path segment of given url");
|
||||
let mut project_url: Vec<String> = Vec::with_capacity(3);
|
||||
let mut iid: Option<String> = None;
|
||||
|
||||
let mut project_found = false;
|
||||
|
||||
for el in parts {
|
||||
if el == "-" {
|
||||
project_found = true;
|
||||
continue;
|
||||
}
|
||||
if el == "issues" {
|
||||
continue;
|
||||
}
|
||||
if !project_found {
|
||||
project_url.push(String::from(el));
|
||||
} else {
|
||||
// must be the id
|
||||
iid = Some(String::from(el));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(IssueInfo {
|
||||
project: project_url.join("/"),
|
||||
iid: iid
|
||||
.expect("iid of the issue not found")
|
||||
.parse()
|
||||
.expect("could not transform issue id to u64"),
|
||||
})
|
||||
}
|
||||
|
||||
impl From<&Issue2WorkPackageDTO> for crate::openproject::work::WorkPackageWriter {
|
||||
fn from(value: &Issue2WorkPackageDTO) -> Self {
|
||||
crate::openproject::work::WorkPackageWriter {
|
||||
subject: format!(
|
||||
"{} ({}/{})",
|
||||
value.issue.issue.title,
|
||||
value.issue.project.name_with_namespace,
|
||||
value.issue.issue.iid
|
||||
),
|
||||
work_type: "TASK".into(),
|
||||
description: crate::openproject::work::DescriptionWriter {
|
||||
format: "markdown".into(),
|
||||
raw: format!("From gitlab: {}", value.issue.issue.web_url),
|
||||
},
|
||||
assignee: WorkPackageWriterAssignee {
|
||||
href: match &value.assign_to {
|
||||
None => None,
|
||||
Some(w) => Some(w.clone().d_links.d_self.href),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,44 @@
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, GitlabConfig};
|
||||
use crate::error::GeneralError;
|
||||
use gitlab::AsyncGitlab;
|
||||
use gitlab::GitlabBuilder;
|
||||
use gitlab::{AsyncGitlab, GitlabBuilder};
|
||||
use url::Url;
|
||||
|
||||
pub trait ClientProviderTrait {
|
||||
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError>;
|
||||
}
|
||||
|
||||
pub struct ClientProvider {}
|
||||
|
||||
impl ClientProviderTrait for ClientProvider {
|
||||
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError> {
|
||||
for c in &config.gitlab {
|
||||
if url.domain() == Some(c.domain.as_str()) {
|
||||
let client = GitlabBuilder::new("gitlab.com", c.token.clone())
|
||||
.build_async()
|
||||
.await;
|
||||
|
||||
return match client {
|
||||
Ok(new_client) => Ok(new_client),
|
||||
Err(e) => {
|
||||
let new_error = e.into();
|
||||
Err(new_error)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Err(GeneralError {
|
||||
description: format!("No client available for this domain: {:?}", url.domain()),
|
||||
})
|
||||
fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool {
|
||||
if url.domain() == Some(config.domain.as_str()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError> {
|
||||
for c in &config.gitlab {
|
||||
if is_client_for_url(url, c) {
|
||||
let client = GitlabBuilder::new("gitlab.com", c.token.clone())
|
||||
.build_async()
|
||||
.await;
|
||||
|
||||
return match client {
|
||||
Ok(new_client) => Ok(new_client),
|
||||
Err(e) => {
|
||||
let new_error = e.into();
|
||||
Err(new_error)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Err(GeneralError {
|
||||
description: format!("No client available for this domain: {:?}", url.domain()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_client_for_url(url: &Url, config: &Config) -> bool {
|
||||
for c in &config.gitlab {
|
||||
if is_client_for_url(url, c) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
use gitlab::Issue;
|
||||
use gitlab::Project;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Issue {
|
||||
pub iid: u64,
|
||||
pub title: String,
|
||||
pub web_url: String,
|
||||
pub project_id: u64,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name_with_namespace: String,
|
||||
}
|
||||
|
||||
/// A struct which contains Issue and Project
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod action;
|
||||
pub mod client;
|
||||
pub mod issue;
|
||||
|
||||
|
||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// Regroupe la logique commune accessible par les deux binaires
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod debug;
|
||||
pub mod error;
|
||||
pub mod gitea;
|
||||
pub mod gitlab;
|
||||
pub mod openproject;
|
||||
pub mod planning;
|
||||
36
src/main.rs
36
src/main.rs
@@ -3,21 +3,11 @@ extern crate reqwest;
|
||||
extern crate serde;
|
||||
extern crate simple_home_dir;
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod debug;
|
||||
mod error;
|
||||
mod gitlab;
|
||||
mod openproject;
|
||||
mod planning;
|
||||
|
||||
use crate::cli::Commands::{Planning, Test};
|
||||
use crate::cli::Planning::I2work;
|
||||
use crate::error::GeneralError;
|
||||
use cl_cli::cli::Cli;
|
||||
use cl_cli::cli::Commands::{Planning, Test};
|
||||
use cl_cli::cli::Planning::I2work;
|
||||
use cl_cli::error::GeneralError;
|
||||
use clap::Parser;
|
||||
use cli::Cli;
|
||||
use config::Config;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::exit;
|
||||
|
||||
@@ -33,22 +23,20 @@ async fn main() {
|
||||
None => &default_config_path,
|
||||
};
|
||||
|
||||
let config_path_content = match fs::read_to_string(config_path) {
|
||||
Ok(content) => content,
|
||||
let config = match cl_cli::config::build_config(config_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Could not read config file at {:?}, error: {}",
|
||||
config_path, e
|
||||
);
|
||||
let general_error: GeneralError = e.into();
|
||||
println!("{}", general_error.description);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let config: Config = toml::from_str(&*config_path_content).expect("Could not parse config");
|
||||
|
||||
let result = match cli.command {
|
||||
Some(Planning(I2work(args))) => planning::issue2work::issue2work(config, &args).await,
|
||||
Some(Test) => debug::debug(config).await,
|
||||
Some(Planning(I2work(args))) => {
|
||||
cl_cli::planning::issue2work::issue2work_cli(config, &args).await
|
||||
}
|
||||
Some(Test) => cl_cli::debug::debug(config).await,
|
||||
None => Err(GeneralError {
|
||||
description: "No command launched".to_string(),
|
||||
}),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct HalEntity {
|
||||
#[serde(rename = "_type")]
|
||||
pub d_type: String,
|
||||
}
|
||||
// #[derive(Deserialize, Debug)]
|
||||
// pub struct HalEntity {
|
||||
// // #[serde(rename = "_type")]
|
||||
// // pub d_type: String,
|
||||
// }
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Link {
|
||||
pub href: String,
|
||||
pub title: Option<String>,
|
||||
// pub title: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
|
||||
use crate::openproject::hal::HalEntity;
|
||||
// use crate::openproject::hal::HalEntity;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct User {
|
||||
pub href: String,
|
||||
pub title: String,
|
||||
// pub title: String,
|
||||
}
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Links {
|
||||
@@ -13,12 +13,12 @@ pub struct Links {
|
||||
}
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Root {
|
||||
#[serde(rename = "instanceName")]
|
||||
pub instance_name: String,
|
||||
// #[serde(rename = "instanceName")]
|
||||
// pub instance_name: String,
|
||||
#[serde(rename = "_links")]
|
||||
pub links: Links,
|
||||
#[serde(flatten)]
|
||||
pub hal_entity: HalEntity,
|
||||
// #[serde(flatten)]
|
||||
// pub hal_entity: HalEntity,
|
||||
}
|
||||
|
||||
pub trait RootClient {
|
||||
|
||||
@@ -10,10 +10,10 @@ pub struct UserLink {
|
||||
}
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct User {
|
||||
#[serde(rename = "_type")]
|
||||
pub d_type: String,
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
// #[serde(rename = "_type")]
|
||||
// pub d_type: String,
|
||||
// pub id: u64,
|
||||
// pub name: String,
|
||||
#[serde(rename = "_links")]
|
||||
pub d_links: UserLink,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::openproject::user::User;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -24,3 +25,14 @@ pub struct WorkPackage {
|
||||
pub id: u64,
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
impl From<Option<User>> for WorkPackageWriterAssignee {
|
||||
fn from(value: Option<User>) -> Self {
|
||||
WorkPackageWriterAssignee {
|
||||
href: match value {
|
||||
None => None,
|
||||
Some(w) => Some(w.clone().d_links.d_self.href),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,54 @@
|
||||
use crate::cli::Issue2Work;
|
||||
use crate::config::Config;
|
||||
use crate::error::GeneralError;
|
||||
use crate::gitlab::client::{ClientProvider, ClientProviderTrait};
|
||||
use crate::gitlab::issue::IssueBundle;
|
||||
use crate::openproject::client::Client;
|
||||
use crate::openproject::user::{GetMe, User};
|
||||
use crate::openproject::work::WorkPackageWriterAssignee;
|
||||
use gitlab::api::{issues, projects, AsyncQuery};
|
||||
use gitlab::{Issue, Project};
|
||||
use crate::gitea::action::GiteaAction;
|
||||
use crate::gitlab::action::GitlabAction;
|
||||
use crate::planning::{Issue2WorkActionTrait, Issue2WorkResult};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IssueInfo {
|
||||
project: String,
|
||||
iid: u64,
|
||||
struct App {
|
||||
gitlab_issue2work_action: GitlabAction,
|
||||
gitea_issue2work_action: GiteaAction,
|
||||
}
|
||||
|
||||
/// details on how to create a work package from various informations
|
||||
#[derive(Debug)]
|
||||
struct Issue2WorkPackageDTO {
|
||||
pub issue: IssueBundle,
|
||||
pub assign_to: Option<User>,
|
||||
}
|
||||
|
||||
impl From<&Issue2WorkPackageDTO> for crate::openproject::work::WorkPackageWriter {
|
||||
fn from(value: &Issue2WorkPackageDTO) -> Self {
|
||||
crate::openproject::work::WorkPackageWriter {
|
||||
subject: format!(
|
||||
"{} ({}/{})",
|
||||
value.issue.issue.title,
|
||||
value.issue.project.name_with_namespace,
|
||||
value.issue.issue.iid
|
||||
),
|
||||
work_type: "TASK".into(),
|
||||
description: crate::openproject::work::DescriptionWriter {
|
||||
format: "markdown".into(),
|
||||
raw: format!("From gitlab: {}", value.issue.issue.web_url),
|
||||
},
|
||||
assignee: WorkPackageWriterAssignee {
|
||||
href: match &value.assign_to {
|
||||
None => None,
|
||||
Some(w) => Some(w.clone().d_links.d_self.href),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(), GeneralError> {
|
||||
pub async fn handle_issue2work(
|
||||
config: Config,
|
||||
args: &Issue2Work,
|
||||
) -> Result<Issue2WorkResult, GeneralError> {
|
||||
let url = Url::parse(&*args.issue_url).expect("issue_url is not valid");
|
||||
let data = extract_issue_info(&url).unwrap();
|
||||
|
||||
let client = ClientProvider::client_for_url(&url, &config).await?;
|
||||
|
||||
let endpoint = issues::ProjectIssues::builder()
|
||||
.iid(data.iid)
|
||||
.project(String::from(data.project))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let issues: Vec<Issue> = endpoint.query_async(&client).await.unwrap();
|
||||
let issue = issues.first().unwrap();
|
||||
|
||||
let project_endpoint = projects::Project::builder()
|
||||
.project(issue.project_id.value())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let project: Project = project_endpoint.query_async(&client).await.unwrap();
|
||||
|
||||
let issue_bundle = IssueBundle::new(&issue, &project);
|
||||
|
||||
let open_project_client = Client::from_config(&config.openproject);
|
||||
|
||||
let dto = Issue2WorkPackageDTO {
|
||||
issue: issue_bundle,
|
||||
assign_to: match args.assign_to_me {
|
||||
true => {
|
||||
let u = open_project_client.me().await?;
|
||||
Some(u)
|
||||
}
|
||||
false => None,
|
||||
},
|
||||
let app = App {
|
||||
gitlab_issue2work_action: GitlabAction {},
|
||||
gitea_issue2work_action: GiteaAction {},
|
||||
};
|
||||
|
||||
let work_package = open_project_client
|
||||
.create_work_package(&(&dto).into(), &args.project_id)
|
||||
.await?;
|
||||
let result: Issue2WorkResult;
|
||||
if app.gitlab_issue2work_action.supports(&url, &config, args) {
|
||||
result = app
|
||||
.gitlab_issue2work_action
|
||||
.run(&url, &config, args)
|
||||
.await?;
|
||||
} else if app.gitea_issue2work_action.supports(&url, &config, args) {
|
||||
result = app.gitea_issue2work_action.run(&url, &config, args).await?
|
||||
} else {
|
||||
return Err(GeneralError {
|
||||
description: format!("This action is not supported for this url: {}", url),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn issue2work_cli(config: Config, args: &Issue2Work) -> Result<(), GeneralError> {
|
||||
let result = handle_issue2work(config, args).await?;
|
||||
|
||||
println!(
|
||||
"new work package created: {:?}, edit at {}/projects/{}/work_packages/{}",
|
||||
work_package.subject, config.openproject.base_url, args.project_id, work_package.id
|
||||
"new work package created: {:?}, edit at {}",
|
||||
result.subject, result.work_package_url
|
||||
);
|
||||
|
||||
if let Err(e) = open::that(result.work_package_url.as_str()) {
|
||||
println!("failed to open work package in browser: {}", e);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_issue_info(url: &Url) -> Option<IssueInfo> {
|
||||
let parts = url
|
||||
.path_segments()
|
||||
.expect("Could not parse path segment of given url");
|
||||
let mut project_url: Vec<String> = Vec::with_capacity(3);
|
||||
let mut iid: Option<String> = None;
|
||||
|
||||
let mut project_found = false;
|
||||
|
||||
for el in parts {
|
||||
if el == "-" {
|
||||
project_found = true;
|
||||
continue;
|
||||
}
|
||||
if el == "issues" {
|
||||
continue;
|
||||
}
|
||||
if !project_found {
|
||||
project_url.push(String::from(el));
|
||||
} else {
|
||||
// must be the id
|
||||
iid = Some(String::from(el));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(IssueInfo {
|
||||
project: project_url.join("/"),
|
||||
iid: iid
|
||||
.expect("iid of the issue not found")
|
||||
.parse()
|
||||
.expect("could not transform issue id to u64"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1 +1,23 @@
|
||||
pub(crate) mod issue2work;
|
||||
use crate::cli::Issue2Work;
|
||||
use crate::config::Config;
|
||||
use crate::error::GeneralError;
|
||||
use url::Url;
|
||||
|
||||
pub mod issue2work;
|
||||
pub mod utils;
|
||||
|
||||
pub trait Issue2WorkActionTrait {
|
||||
fn run(
|
||||
&self,
|
||||
url: &Url,
|
||||
config: &Config,
|
||||
args: &Issue2Work,
|
||||
) -> impl std::future::Future<Output = Result<Issue2WorkResult, GeneralError>> + Send;
|
||||
|
||||
fn supports(&self, url: &Url, config: &Config, args: &Issue2Work) -> bool;
|
||||
}
|
||||
|
||||
pub struct Issue2WorkResult {
|
||||
pub work_package_url: String,
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
180
src/planning/utils.rs
Normal file
180
src/planning/utils.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
pub enum IssueRelated {
|
||||
OpenProjectIssue(String),
|
||||
}
|
||||
|
||||
pub fn append_related_issues(issue: &IssueRelated, content: &String) -> String {
|
||||
let mut splitted = content.lines();
|
||||
let mut new_content: Vec<String> = Vec::new();
|
||||
|
||||
let mut found = false;
|
||||
let mut iteration_started = false;
|
||||
while let Some(line) = splitted.next() {
|
||||
if line.contains("## Related issues") {
|
||||
found = true;
|
||||
new_content.push(line.parse().unwrap());
|
||||
// we go to the end of the section
|
||||
while let Some(line) = splitted.next() {
|
||||
if line.starts_with("-") || line.starts_with("*") {
|
||||
iteration_started = true;
|
||||
new_content.push(line.parse().unwrap());
|
||||
} else if (line.trim().is_empty()) && iteration_started {
|
||||
new_content.append(&mut add_related_issues_section(issue));
|
||||
iteration_started = false;
|
||||
found = true;
|
||||
new_content.push(line.parse().unwrap());
|
||||
break;
|
||||
} else if line.starts_with("#") {
|
||||
new_content.push("new title found".to_string());
|
||||
found = true;
|
||||
new_content.append(&mut add_related_issues_section(issue));
|
||||
new_content.push("".to_string());
|
||||
new_content.push(line.parse().unwrap());
|
||||
break;
|
||||
} else {
|
||||
new_content.push(line.parse().unwrap());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
new_content.push(line.parse().unwrap());
|
||||
}
|
||||
}
|
||||
if !found || iteration_started {
|
||||
if !found {
|
||||
new_content.append(&mut add_related_issues_title());
|
||||
}
|
||||
new_content.append(&mut add_related_issues_section(issue));
|
||||
}
|
||||
|
||||
new_content.join(&"\n")
|
||||
}
|
||||
|
||||
fn add_related_issues_title() -> Vec<String> {
|
||||
let mut previous: Vec<String> = Vec::new();
|
||||
previous.push("".to_string());
|
||||
previous.push("## Related issues".to_string());
|
||||
previous.push("".to_string());
|
||||
previous
|
||||
}
|
||||
|
||||
fn add_related_issues_section(issue: &IssueRelated) -> Vec<String> {
|
||||
let mut previous: Vec<String> = Vec::new();
|
||||
previous.push(convert_issue_link_items(issue));
|
||||
|
||||
previous
|
||||
}
|
||||
|
||||
fn convert_issue_link_items(issue: &IssueRelated) -> String {
|
||||
match issue {
|
||||
IssueRelated::OpenProjectIssue(issue_url) => format!("- [{}]({})", issue_url, issue_url),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::planning::utils::{append_related_issues, IssueRelated};
|
||||
|
||||
#[test]
|
||||
pub fn test_append_related_issues_content_empty() {
|
||||
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
|
||||
assert_eq!(
|
||||
r#"
|
||||
## Related issues
|
||||
|
||||
- [https://example/wp/1](https://example/wp/1)"#,
|
||||
append_related_issues(&issue, &("".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_append_related_issues_content_not_empty() {
|
||||
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
|
||||
assert_eq!(
|
||||
r#"Something happens.
|
||||
|
||||
## Some title
|
||||
|
||||
Some content
|
||||
|
||||
## Related issues
|
||||
|
||||
- [https://example/wp/1](https://example/wp/1)"#,
|
||||
append_related_issues(
|
||||
&issue,
|
||||
&"Something happens.\n\
|
||||
\n\
|
||||
## Some title\n\
|
||||
\n\
|
||||
Some content"
|
||||
.to_string()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_append_related_issues_existing_section_related_issues() {
|
||||
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
|
||||
assert_eq!(
|
||||
r#"Something happens.
|
||||
|
||||
## Some title
|
||||
|
||||
Some content
|
||||
|
||||
## Related issues
|
||||
|
||||
- [https://example/wp/2](https://example/wp/2)
|
||||
- [https://example/wp/1](https://example/wp/1)"#,
|
||||
append_related_issues(
|
||||
&issue,
|
||||
&r#"Something happens.
|
||||
|
||||
## Some title
|
||||
|
||||
Some content
|
||||
|
||||
## Related issues
|
||||
|
||||
- [https://example/wp/2](https://example/wp/2)"#
|
||||
.to_string()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_append_related_issues_existing_section_related_issues_not_last() {
|
||||
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
|
||||
assert_eq!(
|
||||
r#"Something happens.
|
||||
|
||||
## Some title
|
||||
|
||||
Some content
|
||||
|
||||
## Related issues
|
||||
|
||||
- [https://example/wp/2](https://example/wp/2)
|
||||
- [https://example/wp/1](https://example/wp/1)
|
||||
|
||||
## Other content
|
||||
|
||||
Some other content"#,
|
||||
append_related_issues(
|
||||
&issue,
|
||||
&r#"Something happens.
|
||||
|
||||
## Some title
|
||||
|
||||
Some content
|
||||
|
||||
## Related issues
|
||||
|
||||
- [https://example/wp/2](https://example/wp/2)
|
||||
|
||||
## Other content
|
||||
|
||||
Some other content"#
|
||||
.to_string()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
223
src/webext.rs
Normal file
223
src/webext.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use cl_cli::cli::Issue2Work as Issue2WorkCli;
|
||||
use cl_cli::config::build_config;
|
||||
use cl_cli::error::GeneralError;
|
||||
use cl_cli::planning::issue2work::handle_issue2work;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum InputMessage {
|
||||
Issue2Work(Issue2WorkWebExtMessage),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum OutputContent {
|
||||
Issue2Work(OutputIssue2Work),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "result", content = "data")]
|
||||
enum OutputMessage {
|
||||
Ok(OutputContent),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Input message for creating an issue.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Issue2WorkWebExtMessage {
|
||||
url: String,
|
||||
project: String,
|
||||
}
|
||||
|
||||
/// Output message returned to the web extension.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OutputIssue2Work {
|
||||
created: String,
|
||||
}
|
||||
|
||||
/// Reads a *raw* message from stdin (Native Messaging format).
|
||||
/// - first reads 4 bytes (length)
|
||||
/// - then reads exactly `len` bytes into a Vec<u8>
|
||||
/// Returns Ok(None) if EOF (no more data).
|
||||
fn read_message_bytes(stdin: &mut io::StdinLock<'_>) -> io::Result<Option<Vec<u8>>> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
|
||||
// Lire les 4 octets de longueur
|
||||
match stdin.read_exact(&mut len_buf) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
||||
// Plus de données : fin normale
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Longueur annoncée (endianness native, comme dans la spec)
|
||||
let len = u32::from_ne_bytes(len_buf) as usize;
|
||||
|
||||
// Ici, on peut recevoir un *nombre arbitraire d'octets* (dans la limite de u32).
|
||||
let mut buf = vec![0u8; len];
|
||||
stdin.read_exact(&mut buf)?;
|
||||
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
/// Write a JSON message to stdout with a header of 4 bytes.
|
||||
fn write_json_message<T: serde::Serialize>(
|
||||
stdout: &mut io::StdoutLock<'_>,
|
||||
msg: &T,
|
||||
) -> io::Result<()> {
|
||||
let data =
|
||||
serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
let len = data.len() as u32;
|
||||
let len_buf = len.to_ne_bytes();
|
||||
|
||||
stdout.write_all(&len_buf)?;
|
||||
stdout.write_all(&data)?;
|
||||
stdout.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// "Logique métier" : construit une URL "created" à partir de l'entrée.
|
||||
async fn handle_business_logic(input: InputMessage) -> Result<OutputContent, GeneralError> {
|
||||
let mut default_config_path = PathBuf::new();
|
||||
default_config_path.push(simple_home_dir::home_dir().unwrap());
|
||||
default_config_path.push(".config/cl-cli/config.toml");
|
||||
// during dev: default_config_path.push("/tmp/cl-cli/config.toml");
|
||||
let config = match build_config(&default_config_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let result = match input {
|
||||
InputMessage::Issue2Work(i2w) => {
|
||||
let arg = Issue2WorkCli {
|
||||
issue_url: i2w.url,
|
||||
project_id: i2w.project,
|
||||
assign_to_me: false,
|
||||
};
|
||||
let result = handle_issue2work(config, &arg).await?;
|
||||
|
||||
OutputContent::Issue2Work(OutputIssue2Work {
|
||||
created: result.work_package_url,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
eprintln!("Native host Rust démarré.");
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut stdin_lock = stdin.lock();
|
||||
let stdout = io::stdout();
|
||||
let mut stdout_lock = stdout.lock();
|
||||
|
||||
loop {
|
||||
match read_message_bytes(&mut stdin_lock) {
|
||||
Ok(Some(raw_bytes)) => {
|
||||
eprintln!("Reçu {} octets depuis l'extension.", raw_bytes.len());
|
||||
|
||||
// On parse le JSON *après* avoir lu tous les octets.
|
||||
let input: InputMessage = match serde_json::from_slice(&raw_bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("JSON invalide reçu: {e}");
|
||||
// Option : renvoyer une erreur JSON au lieu d'abandonner
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Logique métier -> OutputMessage
|
||||
let output = match handle_business_logic(input).await {
|
||||
Ok(v) => OutputMessage::Ok(v),
|
||||
Err(e) => OutputMessage::Error(e.description),
|
||||
};
|
||||
|
||||
if let Err(e) = write_json_message(&mut stdout_lock, &output) {
|
||||
eprintln!("Erreur lors de l'écriture de la réponse: {e}");
|
||||
// Si on ne sait plus écrire, on termine.
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
eprintln!("EOF sur stdin, arrêt du host.");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Erreur de lecture: {e}");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn deserializes_issue2work_variant() {
|
||||
// Given: a JSON with the external tag "type" matching the enum variant name
|
||||
let payload = json!({
|
||||
"type": "Issue2Work",
|
||||
"url": "https://example.com/issues/123",
|
||||
"project": "test"
|
||||
});
|
||||
|
||||
// When: deserializing to InputMessage
|
||||
let msg: InputMessage = serde_json::from_value(payload).expect("valid enum JSON");
|
||||
|
||||
// Then: we get the correct variant and fields
|
||||
match msg {
|
||||
InputMessage::Issue2Work(inner) => {
|
||||
assert_eq!(inner.url, "https://example.com/issues/123");
|
||||
assert_eq!(inner.project, "test");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_on_unknown_type_tag() {
|
||||
let payload = json!({
|
||||
"type": "Unknown",
|
||||
"url": "https://example.com/issues/123",
|
||||
"project": "test"
|
||||
});
|
||||
|
||||
let err = serde_json::from_value::<InputMessage>(payload).unwrap_err();
|
||||
// Basic sanity check that it's a data error mentioning the unrecognized variant
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("unknown variant") || msg.contains("unknown") || msg.contains("expected"),
|
||||
"unexpected error message: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_when_missing_required_fields() {
|
||||
// Missing "url" and "project"
|
||||
let payload = json!({
|
||||
"type": "Issue2Work"
|
||||
});
|
||||
|
||||
let err = serde_json::from_value::<InputMessage>(payload).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("missing field"),
|
||||
"unexpected error message: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
8
web-extension/.idea/.gitignore
generated
vendored
Normal file
8
web-extension/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
8
web-extension/.idea/modules.xml
generated
Normal file
8
web-extension/.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/web-extension.iml" filepath="$PROJECT_DIR$/.idea/web-extension.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
web-extension/.idea/vcs.xml
generated
Normal file
6
web-extension/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
12
web-extension/.idea/web-extension.iml
generated
Normal file
12
web-extension/.idea/web-extension.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
1
web-extension/.nvmrc
Normal file
1
web-extension/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
3
web-extension/cl/.gitignore
vendored
Normal file
3
web-extension/cl/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/*
|
||||
dist/*
|
||||
web-ext-artifacts/*
|
||||
BIN
web-extension/cl/icons/img-48.png
Normal file
BIN
web-extension/cl/icons/img-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 B |
26
web-extension/cl/manifest.json
Normal file
26
web-extension/cl/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Champs-Libres Helper",
|
||||
"version": "0.4",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "helper@champs-libres-coop",
|
||||
"strict_min_version": "143.0"
|
||||
}
|
||||
},
|
||||
|
||||
"description": "Aide pour les champs-libres",
|
||||
|
||||
"icons": {
|
||||
"48": "icons/img-48.png"
|
||||
},
|
||||
|
||||
"permissions": ["menus", "nativeMessaging", "tabs"],
|
||||
"host_permissions": [
|
||||
"*://*/*"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["dist/background/background.js"],
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
6194
web-extension/cl/package-lock.json
generated
Normal file
6194
web-extension/cl/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
web-extension/cl/package.json
Normal file
21
web-extension/cl/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "web-extension",
|
||||
"version": "0.4.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"check": "tsc --noEmit",
|
||||
"build": "tsc",
|
||||
"package": "tsc && web-ext build --overwrite-dest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@types/firefox-webext-browser": "^143.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"web-ext": "^7.6.0"
|
||||
}
|
||||
}
|
||||
68
web-extension/cl/src/background/background.ts
Normal file
68
web-extension/cl/src/background/background.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {isOutputMessageError, isOutputMessageSuccess} from "./types.js";
|
||||
|
||||
const projects: { name: string, slug: string }[] = [
|
||||
//{name: "test (dev only)", slug: "test"},
|
||||
{name: "Adminsys", slug: "champs-libres-adminsys"},
|
||||
{name: "Be-Arbres", slug: "be-arbres"},
|
||||
{name: "Chill", slug: "chill"},
|
||||
{name: "Chill > AMLI", slug: "amli"},
|
||||
{name: "Chill > Haute-Vienne", slug: "haute-vienne"},
|
||||
{name: "Chill > Petits Chills", slug: "petits-chill"},
|
||||
{name: "Chill > Samu Social", slug: "samu-social"},
|
||||
{name: "Chill > Vendée", slug: "vendee"},
|
||||
{name: "Osiris", slug: "osiris"},
|
||||
{name: "Raponmap", slug: "raponmap"}
|
||||
]
|
||||
|
||||
for (const project of projects) {
|
||||
browser.menus.create(
|
||||
{
|
||||
id: "cl-issue-2-wp-" + project.slug,
|
||||
title: project.name,
|
||||
contexts: ["link"],
|
||||
type: "radio"
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
browser.menus.onClicked.addListener(async function(info, tab) {
|
||||
console.log("info", info);
|
||||
|
||||
if (typeof info.menuItemId === "string") {
|
||||
console.log("menuItemId", info.menuItemId);
|
||||
if (info.menuItemId.startsWith("cl-issue-2-wp-")) {
|
||||
console.log("start with cl-issue-2-wp-");
|
||||
const slug = info.menuItemId.replace("cl-issue-2-wp-", "");
|
||||
console.log("slug", slug);
|
||||
if (typeof info.linkUrl === "string") {
|
||||
await convertToWorkPackage(slug, info.linkUrl);
|
||||
} else {
|
||||
console.error("linkUrl is not a string");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function convertToWorkPackage(slug: string, url: string): Promise<void> {
|
||||
const message = {
|
||||
type: "Issue2Work",
|
||||
url: url,
|
||||
project: slug,
|
||||
};
|
||||
try {
|
||||
const sending: unknown = await browser.runtime.sendNativeMessage("cl_cli", message);
|
||||
if (isOutputMessageError(sending)) {
|
||||
console.error("error while handling message", sending.message);
|
||||
} else if (isOutputMessageSuccess(sending)) {
|
||||
try {
|
||||
await browser.tabs.create({
|
||||
url: sending.data.created,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error while creating new tab", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while handling message", error);
|
||||
}
|
||||
}
|
||||
44
web-extension/cl/src/background/types.ts
Normal file
44
web-extension/cl/src/background/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
export type OutputMessageError = {
|
||||
result: "Error",
|
||||
message: string,
|
||||
}
|
||||
|
||||
export type OutputIssue2Work = {
|
||||
type: "Issue2Work";
|
||||
created: string;
|
||||
}
|
||||
|
||||
export type OutputMessageSuccess = {
|
||||
result: 'Ok'
|
||||
data: OutputIssue2Work;
|
||||
}
|
||||
|
||||
export type OutputMessage = OutputMessageError | OutputMessageSuccess;
|
||||
|
||||
export function isOutputMessage(value: unknown): value is OutputMessage {
|
||||
return isOutputMessageError(value) || isOutputMessageSuccess(value);
|
||||
}
|
||||
|
||||
export function isOutputMessageError(value: unknown): value is OutputMessageError {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'result' in value &&
|
||||
value.result === 'Error' &&
|
||||
'message' in value &&
|
||||
typeof value.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function isOutputMessageSuccess(value: unknown): value is OutputMessageSuccess {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'result' in value &&
|
||||
value.result === 'Ok' &&
|
||||
'data' in value &&
|
||||
typeof value.data === 'object' &&
|
||||
value.data !== null
|
||||
);
|
||||
}
|
||||
19
web-extension/cl/tsconfig.json
Normal file
19
web-extension/cl/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "ESNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": false,
|
||||
"types": [
|
||||
"firefox-webext-browser"
|
||||
]
|
||||
},
|
||||
"include": ["./src/**/*.ts"],
|
||||
"exclude": ["./node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user