Compare commits

..

3 Commits

Author SHA1 Message Date
julienfastre 1ad8b56892 WIP 2024-10-18 10:16:27 +02:00
julienfastre 2d103e2295 First impl of planning load 2024-05-29 23:12:52 +02:00
julienfastre b49c08aed3 Added planning load module and updated dependencies
A new module 'planning_load' has been added to the 'planning' directory, handling workload planning tasks. Updates have also been made to project dependencies in Cargo.lock and Cargo.toml. The added dependencies are necessary for the new functionalities in the 'planning_load' module. Also, a few adjustments were made to allow better access and error handling across packages.
2024-05-22 23:05:43 +02:00
53 changed files with 1429 additions and 8848 deletions
@@ -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
@@ -0,0 +1,3 @@
kind: Added
body: Allow to configure multiple gitlab instances
time: 2024-03-17T21:35:56.447907065+01:00
-5
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
-3
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
-3
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"
-3
View File
@@ -1,3 +0,0 @@
## v0.4.1 - 2025-10-25
### Fixed
* Fix dependencies
@@ -20,14 +20,17 @@ jobs:
id: read_release
with:
path: .changes/${{ github.ref_name }}.md
- name: Release
uses: https://gitea.com/akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
- 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
with:
files: |-
target/release/cl-cli
config.toml.dist
md5sum: true
sha256sum: true
body_path: .changes/${{ github.ref_name }}.md
api_key: '${{secrets.RELEASE_TOKEN}}'
body: |
${{ steps.read_release.outputs.content }}
-25
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
-18
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).
## 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
Generated
+858 -1252
View File
File diff suppressed because it is too large Load Diff
+9 -17
View File
@@ -5,23 +5,15 @@ 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.1804.0"
reqwest = "0.12.24"
serde = { version = "1.0.228", features = ["derive"] }
toml = "0.9.8"
url = "2.5.7"
gitlab = "0.1607.0"
reqwest = "0.11.23"
serde = { version = "1.0.158", features = ["derive"] }
toml = "0.7.3"
url = "2.3.1"
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
simple-home-dir = "0.5.2"
log = "0.4.28"
open = "5.3.2"
serde_json = "1.0.145"
simple-home-dir = "0.2.1"
log = "0.4.17"
tabled = "0.15.0"
iso8601 = { version = "0.6.1" , features = ["serde"]}
+1 -6
View File
@@ -1,11 +1,6 @@
[[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
+5 -4
View File
@@ -3,7 +3,7 @@ use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
pub struct Cli {
pub(crate) struct Cli {
#[arg(short, long, value_name = "FILE")]
pub config: Option<PathBuf>,
@@ -12,19 +12,20 @@ pub struct Cli {
}
#[derive(Subcommand)]
pub enum Commands {
pub(crate) enum Commands {
#[command(subcommand)]
Planning(Planning),
Test,
}
#[derive(Subcommand)]
pub enum Planning {
pub(crate) enum Planning {
I2work(Issue2Work),
Load,
}
#[derive(Args, Debug)]
pub struct Issue2Work {
pub(crate) struct Issue2Work {
pub issue_url: String,
pub project_id: String,
#[arg(short, long)]
+3 -43
View File
@@ -1,59 +1,19 @@
use crate::error::GeneralError;
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Deserialize, Debug)]
pub struct Config {
pub(crate) struct Config {
pub gitlab: Vec<GitlabConfig>,
pub gitea: Vec<GiteaConfig>,
pub openproject: OpenProjectConfig,
}
#[derive(Deserialize, Debug)]
pub struct GitlabConfig {
pub(crate) struct GitlabConfig {
pub token: String,
pub domain: String,
}
#[derive(Deserialize, Debug)]
pub struct GiteaConfig {
pub token: String,
pub domain: String,
}
#[derive(Deserialize, Debug)]
pub struct OpenProjectConfig {
pub(crate) 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)
}
+4 -18
View File
@@ -1,24 +1,12 @@
use crate::config::Config;
use crate::error::GeneralError;
use crate::gitea::issue::Issue;
use url::Url;
use crate::openproject::client::Client;
use crate::openproject::root::RootClient;
use crate::openproject::user::GetMe;
pub async fn debug(config: Config) -> Result<(), GeneralError> {
pub(crate) 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");
@@ -28,6 +16,4 @@ pub async fn debug(config: Config) -> Result<(), GeneralError> {
println!("me: {:?}", u);
Ok(())
*/
}
+1 -20
View File
@@ -1,23 +1,4 @@
use reqwest::header::InvalidHeaderValue;
use reqwest::Error;
#[derive(Debug)]
pub struct GeneralError {
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()),
}
}
pub(crate) description: String,
}
-82
View File
@@ -1,82 +0,0 @@
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
View File
@@ -1,144 +0,0 @@
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
View File
@@ -1,64 +0,0 @@
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
View File
@@ -1,4 +0,0 @@
pub(crate) mod action;
pub mod client;
pub mod issue;
pub mod repository;
-9
View File
@@ -1,9 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Repository {
// id: u64,
// name: String,
// owner: String,
pub full_name: String,
}
-147
View File
@@ -1,147 +0,0 @@
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),
},
},
}
}
}
+26 -35
View File
@@ -1,44 +1,35 @@
use crate::config::{Config, GitlabConfig};
use crate::config::Config;
use crate::error::GeneralError;
use gitlab::{AsyncGitlab, GitlabBuilder};
use gitlab::AsyncGitlab;
use gitlab::GitlabBuilder;
use url::Url;
fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool {
if url.domain() == Some(config.domain.as_str()) {
return true;
}
false
pub trait ClientProviderTrait {
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError>;
}
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;
pub struct ClientProvider {}
return match client {
Ok(new_client) => Ok(new_client),
Err(e) => {
let new_error = e.into();
Err(new_error)
}
};
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()),
})
}
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
}
+2 -15
View File
@@ -1,18 +1,5 @@
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,
}
use gitlab::Issue;
use gitlab::Project;
/// A struct which contains Issue and Project
#[derive(Debug)]
-1
View File
@@ -1,4 +1,3 @@
pub mod action;
pub mod client;
pub mod issue;
-9
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;
+26 -12
View File
@@ -3,11 +3,22 @@ extern crate reqwest;
extern crate serde;
extern crate simple_home_dir;
use cl_cli::cli::Cli;
use cl_cli::cli::Commands::{Planning, Test};
use cl_cli::cli::Planning::I2work;
use cl_cli::error::GeneralError;
mod cli;
mod config;
mod debug;
mod error;
mod gitlab;
mod openproject;
mod planning;
mod utils;
use crate::cli::Commands::{Planning, Test};
use crate::cli::Planning::I2work;
use crate::error::GeneralError;
use clap::Parser;
use cli::Cli;
use config::Config;
use std::fs;
use std::path::PathBuf;
use std::process::exit;
@@ -23,20 +34,23 @@ async fn main() {
None => &default_config_path,
};
let config = match cl_cli::config::build_config(config_path) {
Ok(c) => c,
let config_path_content = match fs::read_to_string(config_path) {
Ok(content) => content,
Err(e) => {
let general_error: GeneralError = e.into();
println!("{}", general_error.description);
println!(
"Could not read config file at {:?}, error: {}",
config_path, e
);
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))) => {
cl_cli::planning::issue2work::issue2work_cli(config, &args).await
}
Some(Test) => cl_cli::debug::debug(config).await,
Some(Planning(I2work(args))) => planning::issue2work::issue2work(config, &args).await,
Some(Planning(cli::Planning::Load)) => planning::planning_load::planning_load(config).await,
Some(Test) => debug::debug(config).await,
None => Err(GeneralError {
description: "No command launched".to_string(),
}),
+2 -2
View File
@@ -6,8 +6,8 @@ use serde::Deserialize;
use std::error::Error;
#[derive(Deserialize, Debug)]
pub(crate) struct OpenProjectError {
pub(crate) description: String,
pub struct OpenProjectError {
pub description: String,
}
impl From<reqwest::Error> for OpenProjectError {
+6 -6
View File
@@ -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
View File
@@ -3,3 +3,4 @@ mod hal;
pub(crate) mod root;
pub(crate) mod user;
pub(crate) mod work;
pub(crate) mod project;
+7
View File
@@ -0,0 +1,7 @@
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)]
pub struct ProjectPartial {
href: String,
pub(crate) title: String,
}
+6 -6
View File
@@ -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 {
+35 -4
View File
@@ -8,16 +8,47 @@ pub struct UserLink {
#[serde(rename = "self")]
pub d_self: Link,
}
#[derive(Deserialize, Debug, Clone)]
pub struct UserPartial {
pub href: Option<String>,
pub title: Option<String>,
}
impl UserPartial {
pub fn is_null(&self) -> bool {
self.href.is_none()
}
}
impl PartialEq<Self> for UserPartial {
fn eq(&self, other: &Self) -> bool {
self.href == other.href
}
}
#[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,
}
impl PartialEq<Self> for User {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for User {
}
pub trait GetMe {
async fn me(&self) -> Result<User, OpenProjectError>;
}
+131 -10
View File
@@ -1,5 +1,16 @@
use crate::openproject::user::User;
use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
use crate::openproject::hal::Link;
use iso8601::Duration;
use serde::{Deserialize, Serialize};
use crate::openproject::project::ProjectPartial;
use crate::openproject::user::{User, UserLink, UserPartial};
#[derive(Deserialize, Debug, Clone)]
pub struct BudgetPartial {
pub(crate) href: Option<String>,
pub(crate) title: Option<String>,
}
#[derive(Serialize, Debug)]
pub struct WorkPackageWriterAssignee {
@@ -20,19 +31,129 @@ pub struct DescriptionWriter {
pub(crate) raw: String,
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
pub struct WorkPackageLinks {
pub(crate) assignee: Option<UserPartial>,
pub(crate) project: ProjectPartial,
pub(crate) budget: BudgetPartial,
}
#[derive(Deserialize, Debug, Clone)]
pub struct WorkPackage {
pub id: u64,
pub subject: String,
#[serde(alias = "estimatedTime")]
pub estimated_time: Option<Duration>,
#[serde(alias = "spentTime")]
pub spent_time: Option<Duration>,
#[serde(alias = "_links")]
pub links: WorkPackageLinks,
}
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),
},
}
#[derive(Deserialize, Debug)]
pub struct WorkPackageCollectionLinks {
#[serde(alias = "nextByOffset")]
pub next_by_offset: Option<Link>,
}
#[derive(Deserialize, Debug)]
pub struct WorkPackagesElements {
pub elements: Vec<WorkPackage>,
}
#[derive(Deserialize, Debug)]
pub struct WorkPackageCollection {
pub total: u64,
pub count: u64,
pub offset: u64,
#[serde(alias = "pageSize")]
pub page_size: u64,
#[serde(alias = "_links")]
pub links: WorkPackageCollectionLinks,
#[serde(alias = "_embedded")]
pub embedded: WorkPackagesElements,
}
impl WorkPackageCollection {
pub fn has_next(&self) -> bool {
self.links.next_by_offset.is_some()
}
pub fn is_last(&self) -> bool {
!self.has_next()
}
}
pub trait WorkPackageCollectionClient {
async fn work_package(&self) -> Result<Vec<WorkPackage>, OpenProjectError>;
}
trait GetWorkPackageCollectionClient {
async fn first_work_packages_collection(
&self,
) -> Result<WorkPackageCollection, OpenProjectError>;
async fn next_work_packages_collection(
&self,
current: &WorkPackageCollection,
) -> Result<WorkPackageCollection, OpenProjectError>;
}
impl GetWorkPackageCollectionClient for Client {
async fn first_work_packages_collection(
&self,
) -> Result<WorkPackageCollection, OpenProjectError> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/api/v3/work_packages", self.base_url))
.basic_auth("apikey", Some(&self.token))
.send()
.await?;
let collection = handle_response_status(response, "could not find work packages").await?;
Ok(collection)
}
async fn next_work_packages_collection(
&self,
current: &WorkPackageCollection,
) -> Result<WorkPackageCollection, OpenProjectError> {
let client = reqwest::Client::new();
let response = client
.get(format!(
"{}/{}",
self.base_url,
current.links.next_by_offset.clone().unwrap().href
))
.basic_auth("apikey", Some(&self.token))
// .query(&[("filters", r##"[{"startDate":{"operator": "<=", "values": ["2024-06-30"]}, {"endDate":{"operator": ">d", "values": ["2024-06-01"]}]"##)])
.send()
.await?;
let collection = handle_response_status(response, "could not find work packages").await?;
Ok(collection)
}
}
impl WorkPackageCollectionClient for Client {
async fn work_package(&self) -> Result<Vec<WorkPackage>, OpenProjectError> {
let mut work_packages: Vec<WorkPackage> = vec![];
let mut collection = self.first_work_packages_collection().await?;
for w in collection.embedded.elements.iter_mut() {
work_packages.push(w.clone());
}
while !collection.is_last() {
collection = self.next_work_packages_collection(&collection).await?;
for w in collection.embedded.elements.iter() {
work_packages.push(w.clone());
}
}
Ok(work_packages)
}
}
+116 -38
View File
@@ -1,54 +1,132 @@
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use crate::gitea::action::GiteaAction;
use crate::gitlab::action::GitlabAction;
use crate::planning::{Issue2WorkActionTrait, Issue2WorkResult};
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 url::Url;
struct App {
gitlab_issue2work_action: GitlabAction,
gitea_issue2work_action: GiteaAction,
#[derive(Debug)]
struct IssueInfo {
project: String,
iid: u64,
}
pub async fn handle_issue2work(
config: Config,
args: &Issue2Work,
) -> Result<Issue2WorkResult, GeneralError> {
/// 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> {
let url = Url::parse(&*args.issue_url).expect("issue_url is not valid");
let app = App {
gitlab_issue2work_action: GitlabAction {},
gitea_issue2work_action: GiteaAction {},
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 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?;
let work_package = open_project_client
.create_work_package(&(&dto).into(), &args.project_id)
.await?;
println!(
"new work package created: {:?}, edit at {}",
result.subject, result.work_package_url
"new work package created: {:?}, edit at {}/projects/{}/work_packages/{}",
work_package.subject, config.openproject.base_url, args.project_id, work_package.id
);
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"),
})
}
+2 -23
View File
@@ -1,23 +1,2 @@
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,
}
pub(crate) mod issue2work;
pub(crate) mod planning_load;
+142
View File
@@ -0,0 +1,142 @@
use std::hash::Hash;
use tabled::settings::Style;
use crate::config::Config;
use crate::error::GeneralError;
use crate::openproject::client::Client;
use crate::openproject::user::UserPartial;
use crate::openproject::work::{WorkPackage, WorkPackageCollectionClient};
pub struct PlanningLoadConfig {
}
enum CellContent {
S(String),
N(f64),
}
impl From<CellContent> for String {
fn from(value: CellContent) -> Self {
match value {
CellContent::S(s) => s,
CellContent::N(n) => n.to_string()
}
}
}
struct UserList {
users: Vec<UserPartial>,
}
impl UserList {
fn from_work_packages(work_packages: &Vec<WorkPackage>) -> Self {
let mut users = vec![];
for w in work_packages {
if w.links.assignee.is_none() {
continue;
}
let u: &UserPartial = w.links.assignee.as_ref().unwrap();
if users.contains(u) {
continue;
}
users.push(u.clone());
}
users.sort_by(|a, b| a.href.partial_cmp(&b.href).unwrap());
UserList { users }
}
}
mod table_load {
use tabled::builder::Builder;
use crate::openproject::work::WorkPackage;
use crate::planning::planning_load::{CellContent, UserList};
use crate::utils::duration_to_seconds_f64;
pub struct Table {
work_packages: Vec<WorkPackage>,
user_list: UserList,
}
impl<'a> Table {
pub fn new(mut work_packages: Vec<WorkPackage>) -> Self {
let user_list = UserList::from_work_packages(&work_packages);
work_packages.sort_by(|a, b| {
if a.links.project.title != b.links.project.title {
return a.links.project.title.partial_cmp(&b.links.project.title).unwrap();
}
if a.links.budget.href.as_ref().unwrap_or(&"".to_string()) != b.links.budget.href.as_ref().unwrap_or(&"".to_string()) {
return a.links.budget.href.as_ref().unwrap_or(&"".to_string()).partial_cmp(
b.links.budget.href.as_ref().unwrap_or(&"".to_string())
).unwrap();
}
return a.id.partial_cmp(&b.id).unwrap();
});
Table {work_packages, user_list}
}
pub fn to_rows(&self) -> tabled::Table {
let mut builder = Builder::default();
let mut header = vec![
"Projet".to_string(),
"Buddget".to_string(),
"Tâche".to_string()
];
for u in &self.user_list.users {
header.push(u.title.as_ref().unwrap_or(&"".to_string()).clone());
}
header.push("Non assigné".to_string());
builder.push_record(header);
for work_package in &self.work_packages {
let mut row = Vec::with_capacity(3 + self.user_list.users.len() + 1);
row.push(CellContent::S(work_package.links.project.title.clone()));
row.push(CellContent::S(work_package.links.budget.title.as_ref().unwrap_or(&"".to_string()).clone()));
row.push(CellContent::S(format!("{} ({})", work_package.subject, work_package.id)));
for u in &self.user_list.users {
if work_package.links.assignee.is_some() && work_package.links.assignee.as_ref().unwrap().eq(u) {
row.push({
match work_package.estimated_time {
Some(duration) => CellContent::N(duration_to_seconds_f64(&duration)/3_600_f64),
None => CellContent::S("".to_string())
}
});
} else {
row.push(CellContent::S("".to_string()));
}
if work_package.links.assignee.is_none() {
row.push(CellContent::S("".to_string()));
}
}
builder.push_record(row);
}
builder.build()
}
}
}
pub async fn planning_load(config: Config) -> Result<(), GeneralError> {
let open_project_client = Client::from_config(&config.openproject);
let work_packages = open_project_client.work_package().await?;
let table_data = table_load::Table::new(work_packages);
let mut table = table_data.to_rows();
table.with(Style::markdown());
println!("{}", table);
Ok(())
}
-180
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()
),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
use iso8601::Duration;
use iso8601::Duration::{Weeks, YMDHMS};
pub fn duration_to_seconds_f64(duration: &Duration) -> f64 {
if duration.is_zero() {
return 0f64;
}
match duration {
Weeks(w) => f64::from(*w) * 24f64 * 7f64 * 3600f64,
YMDHMS{year, month, day, hour, minute, second, millisecond} =>
f64::from(*day) * 86400f64
+ f64::from(*hour) * 3600f64
+ f64::from(*minute) * 60f64
+ f64::from(*second)
}
}
pub fn empty_duration() -> Duration {
YMDHMS {
year: 0,
month: 0,
day: 0,
hour: 0,
minute: 0,
second: 0,
millisecond: 0
}
}
-223
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
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
-8
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>
-6
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>
-12
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>
-1
View File
@@ -1 +0,0 @@
24
-3
View File
@@ -1,3 +0,0 @@
node_modules/*
dist/*
web-ext-artifacts/*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 B

-26
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"
}
}
-6194
View File
File diff suppressed because it is too large Load Diff
-21
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"
}
}
@@ -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);
}
}
-44
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
);
}
-19
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"]
}