3 Commits

Author SHA1 Message Date
2b37c5b18c Add Gitea Action support for issue-to-work conversion
Implemented Gitea actions to support converting Gitea issues into work packages in OpenProject. Modified various modules to include necessary structs, functions, and tests to handle Gitea issues, ensure URL validation, and adapt work package creation based on Gitea issues.
2024-10-25 00:29:34 +02:00
9ef98e5044 Refactor the issue2work action
- separation the logic to handle issues from gitlab into the gitlab mod
- create a kind of visitor pattern to check between the different providers
2024-10-24 23:14:14 +02:00
9aec267e0a WIP on integrate gitea 2024-05-20 19:16:21 +02:00
49 changed files with 945 additions and 8622 deletions

View File

@@ -0,0 +1,4 @@
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

View File

@@ -0,0 +1,3 @@
kind: Added
body: Allow to configure multiple gitlab instances
time: 2024-03-17T21:35:56.447907065+01:00

View File

@@ -1,5 +0,0 @@
## 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

View File

@@ -1,3 +0,0 @@
## v0.3.0 - 2024-11-17
### Added
* Open the newly create work package after the wp has been created

View File

@@ -1,3 +0,0 @@
## 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"

View File

@@ -1,3 +0,0 @@
## v0.4.1 - 2025-10-25
### Fixed
* Fix dependencies

View File

@@ -24,9 +24,3 @@ newlines:
beforeChangelogVersion: 1 beforeChangelogVersion: 1
endOfVersion: 1 endOfVersion: 1
envPrefix: CHANGIE_ envPrefix: CHANGIE_
# update the version in the cargo file, on release
replacements:
- path: Cargo.toml
find: '^version = ".*"'
replace: 'version = "{{.VersionNoPrefix}}"'

View File

@@ -1,4 +1,4 @@
name: Release binary and debian package for cl-cli name: Release binary for cl-cli
on: on:
push: push:
@@ -14,48 +14,23 @@ jobs:
- name: Install rust toolchain - name: Install rust toolchain
uses: https://github.com/dtolnay/rust-toolchain@stable uses: https://github.com/dtolnay/rust-toolchain@stable
- name: Build binaries - name: Build binaries
run: cargo build --quiet --release run: cargo build --release
- name: Install cargo-deb
run: cargo install --quiet cargo-deb
- name: Build Debian package
run: cargo deb
- name: Upload Debian package to Gitea registry
env:
GITEA_OWNER: ${{ github.repository_owner }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
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 \
--user "${GITEA_OWNER}:${GITEA_TOKEN}" \
--upload-file "${DEB_FILE}" \
"${GITEA_SERVER_URL}/api/packages/${GITEA_OWNER}/debian/pool/${DISTRO}/main/upload"
done
- name: Read release content - name: Read release content
uses: https://github.com/jaywcjlove/github-action-read-file@main uses: https://github.com/jaywcjlove/github-action-read-file@main
id: read_release id: read_release
with: with:
path: .changes/${{ github.ref_name }}.md path: .changes/${{ github.ref_name }}.md
- name: Release - name: Setup go for using go gitea actions
uses: https://gitea.com/akkuman/gitea-release-action@v1 uses: https://github.com/actions/setup-go@v4
env: with:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18 go-version: '>=1.20.1'
- name: Use Go Action to release
id: use-go-action
uses: https://gitea.com/actions/release-action@main
with: with:
files: |- files: |-
target/release/cl-cli
config.toml.dist config.toml.dist
md5sum: true api_key: '${{secrets.RELEASE_TOKEN}}'
sha256sum: true body: |
body_path: .changes/${{ github.ref_name }}.md ${{ steps.read_release.outputs.content }}

View File

@@ -1,25 +0,0 @@
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

View File

@@ -1,130 +0,0 @@
# 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 nonzero, 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 networkbound 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 (projectspecific)
- `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 4byte lengthprefixed 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`) reexports 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 userfacing 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 (projectspecific)
- 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)

View File

@@ -6,24 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). 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 ## v0.1.0 - 2024-01-08
### Added ### Added
* Initiate changie versioning * Initiate changie versioning

2049
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,17 @@
[package] [package]
name = "cl-cli" name = "cl-cli"
version = "0.4.1" version = "0.1.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # 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] [dependencies]
clap = { version = "4.1.13", features = ["derive"] } clap = { version = "4.1.13", features = ["derive"] }
gitlab = "0.1804.0" gitlab = "0.1607.0"
reqwest = "0.12.24" reqwest = "0.11.23"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.158", features = ["derive"] }
toml = "0.9.8" toml = "0.7.3"
url = "2.5.7" url = "2.3.1"
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] } tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
simple-home-dir = "0.5.2" simple-home-dir = "0.2.1"
log = "0.4.28" log = "0.4.17"
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"],
]

View File

@@ -1,11 +1,6 @@
[[gitlab]] [gitlab]
# generate from https://gitlab.com/-/user_settings/personal_access_tokens # generate from https://gitlab.com/-/user_settings/personal_access_tokens
token = "glpat-example" token = "glpat-example"
domain = "gitlab.com"
[[gitea]]
token = "abcdexempletoken"
domain = "gitea.champs-libres.be"
[openproject] [openproject]
# generate api token from https://champs-libres.openproject.com/my/access_token # generate api token from https://champs-libres.openproject.com/my/access_token

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
#[derive(Parser)] #[derive(Parser)]
pub struct Cli { pub(crate) struct Cli {
#[arg(short, long, value_name = "FILE")] #[arg(short, long, value_name = "FILE")]
pub config: Option<PathBuf>, pub config: Option<PathBuf>,
@@ -12,19 +12,19 @@ pub struct Cli {
} }
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Commands { pub(crate) enum Commands {
#[command(subcommand)] #[command(subcommand)]
Planning(Planning), Planning(Planning),
Test, Test,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Planning { pub(crate) enum Planning {
I2work(Issue2Work), I2work(Issue2Work),
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
pub struct Issue2Work { pub(crate) struct Issue2Work {
pub issue_url: String, pub issue_url: String,
pub project_id: String, pub project_id: String,
#[arg(short, long)] #[arg(short, long)]

View File

@@ -1,59 +1,26 @@
use crate::error::GeneralError;
use serde::Deserialize; use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct Config { pub(crate) struct Config {
pub gitlab: Vec<GitlabConfig>, pub gitlab: Vec<GitlabConfig>,
pub gitea: Vec<GiteaConfig>, pub gitea: Vec<GiteaConfig>,
pub openproject: OpenProjectConfig, pub openproject: OpenProjectConfig,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct GitlabConfig { pub(crate) struct GitlabConfig {
pub token: String, pub token: String,
pub domain: String, pub domain: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct GiteaConfig { pub(crate) struct GiteaConfig {
pub token: String, pub token: String,
pub domain: String, pub domain: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct OpenProjectConfig { pub(crate) struct OpenProjectConfig {
pub token: String, pub token: String,
pub base_url: 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)
}

View File

@@ -1,9 +1,12 @@
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitea::issue::Issue; use crate::gitea::issue::Issue;
use crate::openproject::client::Client;
use crate::openproject::root::RootClient;
use crate::openproject::user::GetMe;
use url::Url; use url::Url;
pub async fn debug(config: Config) -> Result<(), GeneralError> { pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
println!("test"); println!("test");
let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap()); let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap());
@@ -16,9 +19,8 @@ pub async fn debug(config: Config) -> Result<(), GeneralError> {
println!("issue: {:?}", issue); println!("issue: {:?}", issue);
Ok(()) return Ok(());
/*
let open_project_client = Client::from_config(&config.openproject); let open_project_client = Client::from_config(&config.openproject);
println!("base_url: {}", open_project_client.base_url); println!("base_url: {}", open_project_client.base_url);
println!("base_url: will get root"); println!("base_url: will get root");
@@ -28,6 +30,4 @@ pub async fn debug(config: Config) -> Result<(), GeneralError> {
println!("me: {:?}", u); println!("me: {:?}", u);
Ok(()) Ok(())
*/
} }

View File

@@ -3,11 +3,11 @@ use reqwest::Error;
#[derive(Debug)] #[derive(Debug)]
pub struct GeneralError { pub struct GeneralError {
pub description: String, pub(crate) description: String,
} }
impl From<InvalidHeaderValue> for GeneralError { impl From<InvalidHeaderValue> for GeneralError {
fn from(_value: InvalidHeaderValue) -> Self { fn from(value: InvalidHeaderValue) -> Self {
GeneralError { GeneralError {
description: "Unable to convert the token into header value".to_string(), description: "Unable to convert the token into header value".to_string(),
} }

View File

@@ -1,28 +1,22 @@
use url::Url;
use crate::cli::Issue2Work; use crate::cli::Issue2Work;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitea::issue::{issue_html_url_to_api, Issue};
use crate::gitea::client::has_client_for_url; 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::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriter; use crate::openproject::work::{WorkPackage, WorkPackageWriter, WorkPackageWriterAssignee};
use crate::planning::utils::{append_related_issues, IssueRelated};
use crate::planning::Issue2WorkActionTrait; use crate::planning::Issue2WorkActionTrait;
use crate::planning::Issue2WorkResult;
use url::Url;
pub(crate) struct GiteaAction {} pub(crate) struct GiteaAction {}
impl Issue2WorkActionTrait for GiteaAction { impl Issue2WorkActionTrait for GiteaAction {
async fn run( async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> {
&self,
url: &Url,
config: &Config,
args: &Issue2Work,
) -> Result<Issue2WorkResult, GeneralError> {
let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap()); 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 issue: Issue = gitea_client
let open_project_client = .get(issue_html_url_to_api(url)?)
crate::openproject::client::Client::from_config(&config.openproject); .await?;
let open_project_client = crate::openproject::client::Client::from_config(&config.openproject);
let work_package = create_work_package_from_issue( let work_package = create_work_package_from_issue(
&issue, &issue,
@@ -32,33 +26,19 @@ impl Issue2WorkActionTrait for GiteaAction {
Some(u) Some(u)
} }
false => None, false => None,
}, }
); );
let work_package = open_project_client let work_package = open_project_client
.create_work_package(&work_package, &args.project_id) .create_work_package(&work_package, &args.project_id)
.await?; .await?;
let url_wp = format!( println!(
"{}/projects/{}/work_packages/{}", "new work package created: {:?}, edit at {}/projects/{}/work_packages/{}",
config.openproject.base_url, args.project_id, work_package.id work_package.subject, config.openproject.base_url, args.project_id, work_package.id
); );
let content = append_related_issues( Ok(())
&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 { fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {
@@ -69,14 +49,15 @@ impl Issue2WorkActionTrait for GiteaAction {
fn create_work_package_from_issue(issue: &Issue, assignee: Option<User>) -> WorkPackageWriter { fn create_work_package_from_issue(issue: &Issue, assignee: Option<User>) -> WorkPackageWriter {
WorkPackageWriter { WorkPackageWriter {
subject: format!( subject: format!(
"{} ({}/{})", "{} ({})",
issue.title, issue.repository.full_name, issue.number issue.title,
issue.repository.full_name
), ),
work_type: "TASK".into(), work_type: "TASK".into(),
description: crate::openproject::work::DescriptionWriter { description: crate::openproject::work::DescriptionWriter {
format: "markdown".into(), format: "markdown".into(),
raw: format!("From Gitea issue: {} \n\n{}", issue.html_url, issue.body), raw: format!("From Gitea issue: {} \n\n{}", issue.html_url, issue.body),
}, },
assignee: assignee.into(), assignee: assignee.into()
} }
} }

View File

@@ -1,15 +1,14 @@
use crate::config::{Config, GiteaConfig}; use crate::config::{Config, GiteaConfig, GitlabConfig};
use crate::error::GeneralError; use crate::error::GeneralError;
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION}; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
use reqwest::{ClientBuilder, StatusCode}; use reqwest::{ClientBuilder, StatusCode};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Serialize;
use url::Url; use url::Url;
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
token: String, token: String,
// base_uri: String, base_uri: String,
} }
fn is_client_for_url(url: &Url, config: &GiteaConfig) -> bool { fn is_client_for_url(url: &Url, config: &GiteaConfig) -> bool {
@@ -29,7 +28,6 @@ pub(crate) fn has_client_for_url(url: &Url, config: &Config) -> bool {
false false
} }
#[allow(dead_code)]
fn client_for_url(url: &Url, config: &Config) -> Result<Client, GeneralError> { fn client_for_url(url: &Url, config: &Config) -> Result<Client, GeneralError> {
for c in &config.gitea { for c in &config.gitea {
if is_client_for_url(url, c) { if is_client_for_url(url, c) {
@@ -47,16 +45,16 @@ impl Client {
Self::new(&config.token, &config.domain) Self::new(&config.token, &config.domain)
} }
pub fn new(token: &String, _domain: &String) -> Self { pub fn new(token: &String, domain: &String) -> Self {
Client { Client {
token: token.clone(), token: token.clone(),
// base_uri: format!("https://{}", domain.clone()), base_uri: format!("https://{}", domain.clone()),
} }
} }
pub async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<T, GeneralError> { pub async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<T, GeneralError> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.append(AUTHORIZATION, format!("token {}", self.token).parse()?); headers.append(AUTHORIZATION, self.token.parse()?);
headers.append(ACCEPT, "application/json".parse()?); headers.append(ACCEPT, "application/json".parse()?);
let client = ClientBuilder::new() let client = ClientBuilder::new()
@@ -81,38 +79,6 @@ impl Client {
}), }),
} }
} }
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)] #[cfg(test)]
@@ -126,19 +92,7 @@ mod test {
token: "<PASSWORD>".into(), token: "<PASSWORD>".into(),
}; };
assert_eq!( assert_eq!(is_client_for_url(&Url::parse("https://gitea.champs-libres.be/something/somewhere").unwrap(), &config), true);
is_client_for_url( assert_eq!(is_client_for_url(&Url::parse("https://somewhere.else/something/somewhere").unwrap(), &config), false);
&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
);
} }
} }

View File

@@ -1,21 +1,27 @@
use crate::error::GeneralError; use crate::gitea::client::Client;
use crate::gitea::repository::Repository; use crate::gitea::repository::Repository;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use url::Url; use url::Url;
use crate::error::GeneralError;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Issue { pub struct Issue {
// pub id: u64, pub id: u64,
pub number: u64, number: u64,
pub title: String, pub title: String,
pub body: String, pub body: String,
pub repository: Repository, pub repository: Repository,
pub html_url: String, pub html_url: String,
} }
#[derive(Debug, Serialize)] pub trait IssueClient {
pub struct IssueWriteSetBody { fn get_issue(owner_or_organisation: &String, repo: &String, number: &u64) -> Option<Issue>;
pub body: String, }
impl IssueClient for Client {
fn get_issue(_owner_or_organisation: &String, _repo: &String, number: &u64) -> Option<Issue> {
todo!()
}
} }
pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> { pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> {
@@ -25,24 +31,13 @@ pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> {
let issue = parts.next().unwrap(); let issue = parts.next().unwrap();
let iid = parts.next().unwrap(); let iid = parts.next().unwrap();
if !issue.eq("issues") { if (!issue.eq("issues")) {
return Err(GeneralError { return Err(GeneralError {
description: format!("Issue url is not valid: {}", url), description: format!("Issue url is not valid: {}", url),
}); });
} }
let url = Url::parse( let url = Url::parse(format!("{}://{}/api/v1/repos/{}/{}/issues/{}", url.scheme(), url.host().unwrap(), domain, repo, iid).as_str()).unwrap();
format!(
"{}://{}/api/v1/repos/{}/{}/issues/{}",
url.scheme(),
url.host().unwrap(),
domain,
repo,
iid
)
.as_str(),
)
.unwrap();
Ok(url) Ok(url)
} }
@@ -56,9 +51,6 @@ mod tests {
let url = Url::parse("https://gitea.champs-libres.be/champs-libres/test/issues/1").unwrap(); let url = Url::parse("https://gitea.champs-libres.be/champs-libres/test/issues/1").unwrap();
let result = issue_html_url_to_api(&url).unwrap(); let result = issue_html_url_to_api(&url).unwrap();
assert_eq!( assert_eq!(result.as_str(), "https://gitea.champs-libres.be/api/v1/repos/champs-libres/test/issues/1");
result.as_str(),
"https://gitea.champs-libres.be/api/v1/repos/champs-libres/test/issues/1"
);
} }
} }

View File

@@ -1,4 +1,4 @@
pub(crate) mod action;
pub mod client; pub mod client;
pub mod issue; pub mod issue;
pub mod repository; pub mod repository;
pub(crate) mod action;

View File

@@ -2,8 +2,8 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Repository { pub struct Repository {
// id: u64, id: u64,
// name: String, name: String,
// owner: String, owner: String,
pub full_name: String, pub full_name: String,
} }

View File

@@ -2,13 +2,13 @@ use crate::cli::Issue2Work;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitlab::client::{client_for_url, has_client_for_url}; use crate::gitlab::client::{client_for_url, has_client_for_url};
use crate::gitlab::issue::{Issue, IssueBundle, Project}; use crate::gitlab::issue::IssueBundle;
use crate::openproject::client::Client; use crate::openproject::client::Client;
use crate::openproject::user::{GetMe, User}; use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriterAssignee; use crate::openproject::work::WorkPackageWriterAssignee;
use crate::planning::Issue2WorkActionTrait; use crate::planning::Issue2WorkActionTrait;
use crate::planning::Issue2WorkResult;
use gitlab::api::{issues, projects, AsyncQuery}; use gitlab::api::{issues, projects, AsyncQuery};
use gitlab::{Issue, Project};
use url::Url; use url::Url;
#[derive(Debug)] #[derive(Debug)]
@@ -27,12 +27,7 @@ struct Issue2WorkPackageDTO {
pub(crate) struct GitlabAction {} pub(crate) struct GitlabAction {}
impl Issue2WorkActionTrait for GitlabAction { impl Issue2WorkActionTrait for GitlabAction {
async fn run( async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> {
&self,
url: &Url,
config: &Config,
args: &Issue2Work,
) -> Result<Issue2WorkResult, GeneralError> {
let client = client_for_url(&url, &config).await?; let client = client_for_url(&url, &config).await?;
let data = extract_issue_info(&url).unwrap(); let data = extract_issue_info(&url).unwrap();
@@ -46,7 +41,7 @@ impl Issue2WorkActionTrait for GitlabAction {
let issue = issues.first().unwrap(); let issue = issues.first().unwrap();
let project_endpoint = projects::Project::builder() let project_endpoint = projects::Project::builder()
.project(issue.project_id) .project(issue.project_id.value())
.build() .build()
.unwrap(); .unwrap();
@@ -71,15 +66,12 @@ impl Issue2WorkActionTrait for GitlabAction {
.create_work_package(&(&dto).into(), &args.project_id) .create_work_package(&(&dto).into(), &args.project_id)
.await?; .await?;
let result = Issue2WorkResult { println!(
work_package_url: format!( "new work package created: {:?}, edit at {}/projects/{}/work_packages/{}",
"{}/projects/{}/work_packages/{}", work_package.subject, config.openproject.base_url, args.project_id, work_package.id
config.openproject.base_url, args.project_id, work_package.id );
),
subject: work_package.subject,
};
Ok(result) Ok(())
} }
fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool { fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {

View File

@@ -3,6 +3,10 @@ use crate::error::GeneralError;
use gitlab::{AsyncGitlab, GitlabBuilder}; use gitlab::{AsyncGitlab, GitlabBuilder};
use url::Url; use url::Url;
pub struct ClientProvider {}
impl ClientProvider {}
fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool { fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool {
if url.domain() == Some(config.domain.as_str()) { if url.domain() == Some(config.domain.as_str()) {
return true; return true;

View File

@@ -1,18 +1,5 @@
use serde::Deserialize; use gitlab::Issue;
use gitlab::Project;
#[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 /// A struct which contains Issue and Project
#[derive(Debug)] #[derive(Debug)]

View File

@@ -1,9 +0,0 @@
// 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;

View File

@@ -3,11 +3,22 @@ extern crate reqwest;
extern crate serde; extern crate serde;
extern crate simple_home_dir; extern crate simple_home_dir;
use cl_cli::cli::Cli; mod cli;
use cl_cli::cli::Commands::{Planning, Test}; mod config;
use cl_cli::cli::Planning::I2work; mod debug;
use cl_cli::error::GeneralError; mod error;
mod gitea;
mod gitlab;
mod openproject;
mod planning;
use crate::cli::Commands::{Planning, Test};
use crate::cli::Planning::I2work;
use crate::error::GeneralError;
use clap::Parser; use clap::Parser;
use cli::Cli;
use config::Config;
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::exit; use std::process::exit;
@@ -23,20 +34,22 @@ async fn main() {
None => &default_config_path, None => &default_config_path,
}; };
let config = match cl_cli::config::build_config(config_path) { let config_path_content = match fs::read_to_string(config_path) {
Ok(c) => c, Ok(content) => content,
Err(e) => { Err(e) => {
let general_error: GeneralError = e.into(); println!(
println!("{}", general_error.description); "Could not read config file at {:?}, error: {}",
config_path, e
);
exit(1); exit(1);
} }
}; };
let config: Config = toml::from_str(&*config_path_content).expect("Could not parse config");
let result = match cli.command { let result = match cli.command {
Some(Planning(I2work(args))) => { Some(Planning(I2work(args))) => planning::issue2work::issue2work(config, &args).await,
cl_cli::planning::issue2work::issue2work_cli(config, &args).await Some(Test) => debug::debug(config).await,
}
Some(Test) => cl_cli::debug::debug(config).await,
None => Err(GeneralError { None => Err(GeneralError {
description: "No command launched".to_string(), description: "No command launched".to_string(),
}), }),

View File

@@ -1,13 +1,13 @@
use serde::Deserialize; use serde::Deserialize;
// #[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
// pub struct HalEntity { pub struct HalEntity {
// // #[serde(rename = "_type")] #[serde(rename = "_type")]
// // pub d_type: String, pub d_type: String,
// } }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Link { pub struct Link {
pub href: String, pub href: String,
// pub title: Option<String>, pub title: Option<String>,
} }

View File

@@ -1,11 +1,11 @@
use crate::openproject::client::{handle_response_status, Client, OpenProjectError}; use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
// use crate::openproject::hal::HalEntity; use crate::openproject::hal::HalEntity;
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct User { pub struct User {
pub href: String, pub href: String,
// pub title: String, pub title: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct Links { pub struct Links {
@@ -13,12 +13,12 @@ pub struct Links {
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct Root { pub struct Root {
// #[serde(rename = "instanceName")] #[serde(rename = "instanceName")]
// pub instance_name: String, pub instance_name: String,
#[serde(rename = "_links")] #[serde(rename = "_links")]
pub links: Links, pub links: Links,
// #[serde(flatten)] #[serde(flatten)]
// pub hal_entity: HalEntity, pub hal_entity: HalEntity,
} }
pub trait RootClient { pub trait RootClient {

View File

@@ -10,10 +10,13 @@ pub struct UserLink {
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct User { pub struct User {
// #[serde(rename = "_type")] #[serde(rename = "_type")]
// pub d_type: String, #[allow(unused_variables)]
// pub id: u64, pub d_type: String,
// pub name: String, #[allow(unused_variables)]
pub id: u64,
#[allow(unused_variables)]
pub name: String,
#[serde(rename = "_links")] #[serde(rename = "_links")]
pub d_links: UserLink, pub d_links: UserLink,
} }

View File

@@ -1,5 +1,5 @@
use crate::openproject::user::User;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::openproject::user::User;
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct WorkPackageWriterAssignee { pub struct WorkPackageWriterAssignee {

View File

@@ -3,7 +3,7 @@ use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitea::action::GiteaAction; use crate::gitea::action::GiteaAction;
use crate::gitlab::action::GitlabAction; use crate::gitlab::action::GitlabAction;
use crate::planning::{Issue2WorkActionTrait, Issue2WorkResult}; use crate::planning::Issue2WorkActionTrait;
use url::Url; use url::Url;
struct App { struct App {
@@ -11,44 +11,20 @@ struct App {
gitea_issue2work_action: GiteaAction, gitea_issue2work_action: GiteaAction,
} }
pub async fn handle_issue2work( pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(), GeneralError> {
config: Config,
args: &Issue2Work,
) -> Result<Issue2WorkResult, GeneralError> {
let url = Url::parse(&*args.issue_url).expect("issue_url is not valid"); let url = Url::parse(&*args.issue_url).expect("issue_url is not valid");
let app = App { let app = App {
gitlab_issue2work_action: GitlabAction {}, gitlab_issue2work_action: GitlabAction {},
gitea_issue2work_action: GiteaAction {}, gitea_issue2work_action: GiteaAction {},
}; };
let result: Issue2WorkResult;
if app.gitlab_issue2work_action.supports(&url, &config, args) { if app.gitlab_issue2work_action.supports(&url, &config, args) {
result = app app.gitlab_issue2work_action.run(&url, &config, args).await
.gitlab_issue2work_action
.run(&url, &config, args)
.await?;
} else if app.gitea_issue2work_action.supports(&url, &config, args) { } else if app.gitea_issue2work_action.supports(&url, &config, args) {
result = app.gitea_issue2work_action.run(&url, &config, args).await? app.gitea_issue2work_action.run(&url, &config, args).await
} else { } else {
return Err(GeneralError { Err(GeneralError {
description: format!("This action is not supported for this url: {}", url), 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 {}",
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(())
} }

View File

@@ -1,23 +1,14 @@
use crate::cli::Issue2Work; use crate::cli::Issue2Work;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitlab::client::ClientProvider;
use gitlab::{AsyncGitlab, GitlabBuilder};
use url::Url; use url::Url;
pub mod issue2work; pub(crate) mod issue2work;
pub mod utils;
pub trait Issue2WorkActionTrait { pub trait Issue2WorkActionTrait {
fn run( async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError>;
&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; fn supports(&self, url: &Url, config: &Config, args: &Issue2Work) -> bool;
} }
pub struct Issue2WorkResult {
pub work_package_url: String,
pub subject: String,
}

View File

@@ -1,180 +0,0 @@
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()
),
);
}
}

View File

@@ -1,223 +0,0 @@
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
View File

@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,8 +0,0 @@
<?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>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -1,12 +0,0 @@
<?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>

View File

@@ -1 +0,0 @@
24

View File

@@ -1,3 +0,0 @@
node_modules/*
dist/*
web-ext-artifacts/*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 B

View File

@@ -1,26 +0,0 @@
{
"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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
{
"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"
}
}

View File

@@ -1,68 +0,0 @@
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);
}
}

View File

@@ -1,44 +0,0 @@
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
);
}

View File

@@ -1,19 +0,0 @@
{
"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"]
}