16 Commits

Author SHA1 Message Date
5084f5b10d fix serialization of output message
All checks were successful
Check go code / build-and-release (push) Successful in 1m16s
2025-11-11 00:45:51 +01:00
55fb234db6 first web extension version 2025-11-11 00:44:49 +01:00
d8db96603b initialize webext 2025-11-07 22:48:18 +01:00
c45e12ed9a Setup a web extension and refactor code 2025-11-07 22:39:06 +01:00
d830f33eec Release v0.4.1
All checks were successful
Release binary for cl-cli / build-and-release (push) Successful in 1m23s
Check go code / build-and-release (push) Successful in 1m6s
2025-10-25 00:37:21 +02:00
825623c9f3 Fix release workflow (#4)
All checks were successful
Check go code / build-and-release (push) Successful in 1m6s
Reviewed-on: #4
Co-authored-by: Julien Fastré <julien.fastre@champs-libres.coop>
Co-committed-by: Julien Fastré <julien.fastre@champs-libres.coop>
2025-10-24 22:35:21 +00:00
81350f38e4 Update dependencies in Cargo.lock to the latest versions
All checks were successful
Check go code / build-and-release (push) Successful in 1m4s
2025-10-25 00:13:23 +02:00
1f2d42e1d5 Update release workflow to include pull_request_target event and specify branches
All checks were successful
Check go code / build-and-release (push) Successful in 1m3s
2025-10-24 23:43:11 +02:00
a0f67464b5 Simplify build-and-release workflow by switching to akkuman/gitea-release-action and enabling checksum generation.
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 7s
2025-10-24 23:41:29 +02:00
ff5699e627 Update workflow to specify branches for push and pull_request events 2025-10-24 23:35:28 +02:00
dc0040c2d1 Add workflow to check Go code on push and pull requests 2025-10-24 23:34:15 +02:00
9950282d6e Update build-and-release workflow to use Gitea-hosted setup-go action
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 7s
2025-10-24 23:33:31 +02:00
7c8e8eb236 Clean code by commenting out some variable, and allowing some dead code. 2025-10-24 23:24:47 +02:00
442b5d25b4 Release v0.4.0
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 1m40s
2025-10-24 18:08:01 +02:00
4763d48290 Add PATCH support in Gitea client and update issue handling
Extend Gitea client with a `patch` method for executing PATCH requests. Introduce `IssueWriteSetBody` struct for serializing issue update payloads. Update Gitea action to append related issues and send PATCH requests to update issue descriptions.
2025-10-24 18:04:28 +02:00
1af6596b51 Enhance issue handling by adding utils module
Introduce `append_related_issues` function and `IssueRelated` enum to manage related issue linking. Update Gitea action to utilize the new functionality for appending related issues in work package content. Add corresponding tests.
2025-10-24 17:32:10 +02:00
40 changed files with 8345 additions and 925 deletions

3
.changes/v0.4.0.md Normal file
View File

@@ -0,0 +1,3 @@
## v0.4.0 - 2025-10-24
### Added
* [planning - i2work] Met à jour le body du ticket gitea avec un lien vers le work package, dans une section "related issues"

3
.changes/v0.4.1.md Normal file
View File

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

View File

@@ -20,17 +20,14 @@ jobs:
id: read_release
with:
path: .changes/${{ github.ref_name }}.md
- name: Setup go for using go gitea actions
uses: https://github.com/actions/setup-go@v4
with:
go-version: '>=1.20.1'
- name: Use Go Action to release
id: use-go-action
uses: https://gitea.com/actions/release-action@main
- name: Release
uses: https://gitea.com/akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
files: |-
target/release/cl-cli
config.toml.dist
api_key: '${{secrets.RELEASE_TOKEN}}'
body: |
${{ steps.read_release.outputs.content }}
md5sum: true
sha256sum: true
body_path: .changes/${{ github.ref_name }}.md

View File

@@ -0,0 +1,25 @@
name: Check go code
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
pull_request_target:
branches:
- '**'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: https://github.com/actions/checkout@v4
- name: Install rust toolchain
uses: https://github.com/dtolnay/rust-toolchain@stable
- name: Build binaries
run: cargo build
- name: Run tests
run: cargo test

View File

@@ -6,6 +6,18 @@ 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

2042
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,14 +5,23 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name="cl-cli"
path="src/main.rs"
[[bin]]
name="webext"
path="src/webext.rs"
[dependencies]
clap = { version = "4.1.13", features = ["derive"] }
gitlab = "0.1607.0"
reqwest = "0.11.23"
serde = { version = "1.0.158", features = ["derive"] }
toml = "0.7.3"
url = "2.3.1"
gitlab = "0.1804.0"
reqwest = "0.12.24"
serde = { version = "1.0.228", features = ["derive"] }
toml = "0.9.8"
url = "2.5.7"
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
simple-home-dir = "0.2.1"
log = "0.4.17"
open = "5.3.1"
simple-home-dir = "0.5.2"
log = "0.4.28"
open = "5.3.2"
serde_json = "1.0.145"

View File

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

View File

@@ -1,26 +1,59 @@
use crate::error::GeneralError;
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Deserialize, Debug)]
pub(crate) struct Config {
pub struct Config {
pub gitlab: Vec<GitlabConfig>,
pub gitea: Vec<GiteaConfig>,
pub openproject: OpenProjectConfig,
}
#[derive(Deserialize, Debug)]
pub(crate) struct GitlabConfig {
pub struct GitlabConfig {
pub token: String,
pub domain: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct GiteaConfig {
pub struct GiteaConfig {
pub token: String,
pub domain: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct OpenProjectConfig {
pub struct OpenProjectConfig {
pub token: String,
pub base_url: String,
}
pub struct BuildConfigError {
msg: String,
}
impl Into<GeneralError> for BuildConfigError {
fn into(self) -> GeneralError {
GeneralError {
description: format!("Could not build config: {}", self.msg),
}
}
}
pub fn build_config(config_path: &Path) -> Result<Config, BuildConfigError> {
let config_path_content = match fs::read_to_string(config_path) {
Ok(content) => content,
Err(e) => {
return Err(BuildConfigError {
msg: format!(
"Could not read a config file at {:?}, error: {}",
config_path, e
),
});
}
};
let config: Config = toml::from_str(&*config_path_content).expect("Could not parse config");
Ok(config)
}

View File

@@ -3,7 +3,7 @@ use crate::error::GeneralError;
use crate::gitea::issue::Issue;
use url::Url;
pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
pub async fn debug(config: Config) -> Result<(), GeneralError> {
println!("test");
let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap());

View File

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

View File

@@ -2,16 +2,23 @@ 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};
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<(), GeneralError> {
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 =
@@ -32,21 +39,26 @@ impl Issue2WorkActionTrait for GiteaAction {
.create_work_package(&work_package, &args.project_id)
.await?;
let url = format!(
let url_wp = format!(
"{}/projects/{}/work_packages/{}",
config.openproject.base_url, args.project_id, work_package.id
);
println!(
"new work package created: {:?}, edit at {}",
work_package.subject, url
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?;
if let Err(e) = open::that(url) {
println!("failed to open work package in browser: {}", e);
};
Ok(())
Ok(Issue2WorkResult {
work_package_url: url_wp,
subject: work_package.subject,
})
}
fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {

View File

@@ -3,12 +3,13 @@ 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,
// base_uri: String,
}
fn is_client_for_url(url: &Url, config: &GiteaConfig) -> bool {
@@ -28,6 +29,7 @@ pub(crate) fn has_client_for_url(url: &Url, config: &Config) -> bool {
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) {
@@ -45,10 +47,10 @@ impl Client {
Self::new(&config.token, &config.domain)
}
pub fn new(token: &String, domain: &String) -> Self {
pub fn new(token: &String, _domain: &String) -> Self {
Client {
token: token.clone(),
base_uri: format!("https://{}", domain.clone()),
// base_uri: format!("https://{}", domain.clone()),
}
}
@@ -79,6 +81,38 @@ 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)]

View File

@@ -1,12 +1,11 @@
use crate::error::GeneralError;
use crate::gitea::client::Client;
use crate::gitea::repository::Repository;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Deserialize)]
pub struct Issue {
pub id: u64,
// pub id: u64,
pub number: u64,
pub title: String,
pub body: String,
@@ -14,14 +13,9 @@ pub struct Issue {
pub html_url: String,
}
pub trait IssueClient {
fn get_issue(owner_or_organisation: &String, repo: &String, number: &u64) -> Option<Issue>;
}
impl IssueClient for Client {
fn get_issue(_owner_or_organisation: &String, _repo: &String, number: &u64) -> Option<Issue> {
todo!()
}
#[derive(Debug, Serialize)]
pub struct IssueWriteSetBody {
pub body: String,
}
pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> {

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,18 @@
use gitlab::Issue;
use gitlab::Project;
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)]
pub struct Issue {
pub iid: u64,
pub title: String,
pub web_url: String,
pub project_id: u64,
pub description: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Project {
pub name_with_namespace: String,
}
/// A struct which contains Issue and Project
#[derive(Debug)]

9
src/lib.rs Normal file
View File

@@ -0,0 +1,9 @@
// Regroupe la logique commune accessible par les deux binaires
pub mod cli;
pub mod config;
pub mod debug;
pub mod error;
pub mod gitea;
pub mod gitlab;
pub mod openproject;
pub mod planning;

View File

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

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>,
}

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 {

View File

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

View File

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

@@ -3,10 +3,21 @@ use crate::config::Config;
use crate::error::GeneralError;
use url::Url;
pub(crate) mod issue2work;
pub mod issue2work;
pub mod utils;
pub trait Issue2WorkActionTrait {
async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError>;
fn run(
&self,
url: &Url,
config: &Config,
args: &Issue2Work,
) -> impl std::future::Future<Output = Result<Issue2WorkResult, GeneralError>> + Send;
fn supports(&self, url: &Url, config: &Config, args: &Issue2Work) -> bool;
}
pub struct Issue2WorkResult {
pub work_package_url: String,
pub subject: String,
}

180
src/planning/utils.rs Normal file
View File

@@ -0,0 +1,180 @@
pub enum IssueRelated {
OpenProjectIssue(String),
}
pub fn append_related_issues(issue: &IssueRelated, content: &String) -> String {
let mut splitted = content.lines();
let mut new_content: Vec<String> = Vec::new();
let mut found = false;
let mut iteration_started = false;
while let Some(line) = splitted.next() {
if line.contains("## Related issues") {
found = true;
new_content.push(line.parse().unwrap());
// we go to the end of the section
while let Some(line) = splitted.next() {
if line.starts_with("-") || line.starts_with("*") {
iteration_started = true;
new_content.push(line.parse().unwrap());
} else if (line.trim().is_empty()) && iteration_started {
new_content.append(&mut add_related_issues_section(issue));
iteration_started = false;
found = true;
new_content.push(line.parse().unwrap());
break;
} else if line.starts_with("#") {
new_content.push("new title found".to_string());
found = true;
new_content.append(&mut add_related_issues_section(issue));
new_content.push("".to_string());
new_content.push(line.parse().unwrap());
break;
} else {
new_content.push(line.parse().unwrap());
}
}
} else {
new_content.push(line.parse().unwrap());
}
}
if !found || iteration_started {
if !found {
new_content.append(&mut add_related_issues_title());
}
new_content.append(&mut add_related_issues_section(issue));
}
new_content.join(&"\n")
}
fn add_related_issues_title() -> Vec<String> {
let mut previous: Vec<String> = Vec::new();
previous.push("".to_string());
previous.push("## Related issues".to_string());
previous.push("".to_string());
previous
}
fn add_related_issues_section(issue: &IssueRelated) -> Vec<String> {
let mut previous: Vec<String> = Vec::new();
previous.push(convert_issue_link_items(issue));
previous
}
fn convert_issue_link_items(issue: &IssueRelated) -> String {
match issue {
IssueRelated::OpenProjectIssue(issue_url) => format!("- [{}]({})", issue_url, issue_url),
}
}
#[cfg(test)]
mod tests {
use crate::planning::utils::{append_related_issues, IssueRelated};
#[test]
pub fn test_append_related_issues_content_empty() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"
## Related issues
- [https://example/wp/1](https://example/wp/1)"#,
append_related_issues(&issue, &("".to_string()))
);
}
#[test]
pub fn test_append_related_issues_content_not_empty() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/1](https://example/wp/1)"#,
append_related_issues(
&issue,
&"Something happens.\n\
\n\
## Some title\n\
\n\
Some content"
.to_string()
),
);
}
#[test]
pub fn test_append_related_issues_existing_section_related_issues() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)
- [https://example/wp/1](https://example/wp/1)"#,
append_related_issues(
&issue,
&r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)"#
.to_string()
),
);
}
#[test]
pub fn test_append_related_issues_existing_section_related_issues_not_last() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)
- [https://example/wp/1](https://example/wp/1)
## Other content
Some other content"#,
append_related_issues(
&issue,
&r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)
## Other content
Some other content"#
.to_string()
),
);
}
}

223
src/webext.rs Normal file
View File

@@ -0,0 +1,223 @@
use cl_cli::cli::Issue2Work as Issue2WorkCli;
use cl_cli::config::build_config;
use cl_cli::error::GeneralError;
use cl_cli::planning::issue2work::handle_issue2work;
use serde::{Deserialize, Serialize};
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process;
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum InputMessage {
Issue2Work(Issue2WorkWebExtMessage),
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
enum OutputContent {
Issue2Work(OutputIssue2Work),
}
#[derive(Debug, Serialize)]
#[serde(tag = "result", content = "data")]
enum OutputMessage {
Ok(OutputContent),
Error(String),
}
/// Input message for creating an issue.
#[derive(Debug, Deserialize)]
struct Issue2WorkWebExtMessage {
url: String,
project: String,
}
/// Output message returned to the web extension.
#[derive(Debug, Serialize)]
struct OutputIssue2Work {
created: String,
}
/// Reads a *raw* message from stdin (Native Messaging format).
/// - first reads 4 bytes (length)
/// - then reads exactly `len` bytes into a Vec<u8>
/// Returns Ok(None) if EOF (no more data).
fn read_message_bytes(stdin: &mut io::StdinLock<'_>) -> io::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
// Lire les 4 octets de longueur
match stdin.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
// Plus de données : fin normale
return Ok(None);
}
Err(e) => return Err(e),
}
// Longueur annoncée (endianness native, comme dans la spec)
let len = u32::from_ne_bytes(len_buf) as usize;
// Ici, on peut recevoir un *nombre arbitraire d'octets* (dans la limite de u32).
let mut buf = vec![0u8; len];
stdin.read_exact(&mut buf)?;
Ok(Some(buf))
}
/// Write a JSON message to stdout with a header of 4 bytes.
fn write_json_message<T: serde::Serialize>(
stdout: &mut io::StdoutLock<'_>,
msg: &T,
) -> io::Result<()> {
let data =
serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let len = data.len() as u32;
let len_buf = len.to_ne_bytes();
stdout.write_all(&len_buf)?;
stdout.write_all(&data)?;
stdout.flush()?;
Ok(())
}
/// "Logique métier" : construit une URL "created" à partir de l'entrée.
async fn handle_business_logic(input: InputMessage) -> Result<OutputContent, GeneralError> {
let mut default_config_path = PathBuf::new();
default_config_path.push(simple_home_dir::home_dir().unwrap());
default_config_path.push(".config/cl-cli/config.toml");
// during dev: default_config_path.push("/tmp/cl-cli/config.toml");
let config = match build_config(&default_config_path) {
Ok(c) => c,
Err(e) => {
return Err(e.into());
}
};
let result = match input {
InputMessage::Issue2Work(i2w) => {
let arg = Issue2WorkCli {
issue_url: i2w.url,
project_id: i2w.project,
assign_to_me: false,
};
let result = handle_issue2work(config, &arg).await?;
OutputContent::Issue2Work(OutputIssue2Work {
created: result.work_package_url,
})
}
};
Ok(result)
}
#[tokio::main]
async fn main() {
eprintln!("Native host Rust démarré.");
let stdin = io::stdin();
let mut stdin_lock = stdin.lock();
let stdout = io::stdout();
let mut stdout_lock = stdout.lock();
loop {
match read_message_bytes(&mut stdin_lock) {
Ok(Some(raw_bytes)) => {
eprintln!("Reçu {} octets depuis l'extension.", raw_bytes.len());
// On parse le JSON *après* avoir lu tous les octets.
let input: InputMessage = match serde_json::from_slice(&raw_bytes) {
Ok(v) => v,
Err(e) => {
eprintln!("JSON invalide reçu: {e}");
// Option : renvoyer une erreur JSON au lieu d'abandonner
continue;
}
};
// Logique métier -> OutputMessage
let output = match handle_business_logic(input).await {
Ok(v) => OutputMessage::Ok(v),
Err(e) => OutputMessage::Error(e.description),
};
if let Err(e) = write_json_message(&mut stdout_lock, &output) {
eprintln!("Erreur lors de l'écriture de la réponse: {e}");
// Si on ne sait plus écrire, on termine.
process::exit(1);
}
}
Ok(None) => {
eprintln!("EOF sur stdin, arrêt du host.");
break;
}
Err(e) => {
eprintln!("Erreur de lecture: {e}");
process::exit(1);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deserializes_issue2work_variant() {
// Given: a JSON with the external tag "type" matching the enum variant name
let payload = json!({
"type": "Issue2Work",
"url": "https://example.com/issues/123",
"project": "test"
});
// When: deserializing to InputMessage
let msg: InputMessage = serde_json::from_value(payload).expect("valid enum JSON");
// Then: we get the correct variant and fields
match msg {
InputMessage::Issue2Work(inner) => {
assert_eq!(inner.url, "https://example.com/issues/123");
assert_eq!(inner.project, "test");
}
}
}
#[test]
fn fails_on_unknown_type_tag() {
let payload = json!({
"type": "Unknown",
"url": "https://example.com/issues/123",
"project": "test"
});
let err = serde_json::from_value::<InputMessage>(payload).unwrap_err();
// Basic sanity check that it's a data error mentioning the unrecognized variant
let msg = err.to_string();
assert!(
msg.contains("unknown variant") || msg.contains("unknown") || msg.contains("expected"),
"unexpected error message: {msg}"
);
}
#[test]
fn fails_when_missing_required_fields() {
// Missing "url" and "project"
let payload = json!({
"type": "Issue2Work"
});
let err = serde_json::from_value::<InputMessage>(payload).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("missing field"),
"unexpected error message: {msg}"
);
}
}

8
web-extension/.idea/.gitignore generated vendored Normal file
View File

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

8
web-extension/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/web-extension.iml" filepath="$PROJECT_DIR$/.idea/web-extension.iml" />
</modules>
</component>
</project>

6
web-extension/.idea/vcs.xml generated Normal file
View File

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

12
web-extension/.idea/web-extension.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

1
web-extension/.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

3
web-extension/cl/.gitignore vendored Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

View File

@@ -0,0 +1,26 @@
{
"manifest_version": 3,
"name": "Champs-Libres Helper",
"version": "0.4",
"browser_specific_settings": {
"gecko": {
"id": "helper@champs-libres-coop",
"strict_min_version": "143.0"
}
},
"description": "Aide pour les champs-libres",
"icons": {
"48": "icons/img-48.png"
},
"permissions": ["menus", "nativeMessaging", "tabs"],
"host_permissions": [
"*://*/*"
],
"background": {
"scripts": ["dist/background/background.js"],
"type": "module"
}
}

6194
web-extension/cl/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "web-extension",
"version": "0.4.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"check": "tsc --noEmit",
"build": "tsc",
"package": "tsc && web-ext build --overwrite-dest"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@types/firefox-webext-browser": "^143.0.0",
"typescript": "^5.9.3",
"web-ext": "^7.6.0"
}
}

View File

@@ -0,0 +1,68 @@
import {isOutputMessageError, isOutputMessageSuccess} from "./types.js";
const projects: { name: string, slug: string }[] = [
//{name: "test (dev only)", slug: "test"},
{name: "Adminsys", slug: "champs-libres-adminsys"},
{name: "Be-Arbres", slug: "be-arbres"},
{name: "Chill", slug: "chill"},
{name: "Chill > AMLI", slug: "amli"},
{name: "Chill > Haute-Vienne", slug: "haute-vienne"},
{name: "Chill > Petits Chills", slug: "petits-chill"},
{name: "Chill > Samu Social", slug: "samu-social"},
{name: "Chill > Vendée", slug: "vendee"},
{name: "Osiris", slug: "osiris"},
{name: "Raponmap", slug: "raponmap"}
]
for (const project of projects) {
browser.menus.create(
{
id: "cl-issue-2-wp-" + project.slug,
title: project.name,
contexts: ["link"],
type: "radio"
},
);
}
browser.menus.onClicked.addListener(async function(info, tab) {
console.log("info", info);
if (typeof info.menuItemId === "string") {
console.log("menuItemId", info.menuItemId);
if (info.menuItemId.startsWith("cl-issue-2-wp-")) {
console.log("start with cl-issue-2-wp-");
const slug = info.menuItemId.replace("cl-issue-2-wp-", "");
console.log("slug", slug);
if (typeof info.linkUrl === "string") {
await convertToWorkPackage(slug, info.linkUrl);
} else {
console.error("linkUrl is not a string");
}
}
}
});
async function convertToWorkPackage(slug: string, url: string): Promise<void> {
const message = {
type: "Issue2Work",
url: url,
project: slug,
};
try {
const sending: unknown = await browser.runtime.sendNativeMessage("cl_cli", message);
if (isOutputMessageError(sending)) {
console.error("error while handling message", sending.message);
} else if (isOutputMessageSuccess(sending)) {
try {
await browser.tabs.create({
url: sending.data.created,
});
} catch (error) {
console.error("error while creating new tab", error);
}
}
} catch (error) {
console.error("error while handling message", error);
}
}

View File

@@ -0,0 +1,44 @@
export type OutputMessageError = {
result: "Error",
message: string,
}
export type OutputIssue2Work = {
type: "Issue2Work";
created: string;
}
export type OutputMessageSuccess = {
result: 'Ok'
data: OutputIssue2Work;
}
export type OutputMessage = OutputMessageError | OutputMessageSuccess;
export function isOutputMessage(value: unknown): value is OutputMessage {
return isOutputMessageError(value) || isOutputMessageSuccess(value);
}
export function isOutputMessageError(value: unknown): value is OutputMessageError {
return (
typeof value === 'object' &&
value !== null &&
'result' in value &&
value.result === 'Error' &&
'message' in value &&
typeof value.message === 'string'
);
}
export function isOutputMessageSuccess(value: unknown): value is OutputMessageSuccess {
return (
typeof value === 'object' &&
value !== null &&
'result' in value &&
value.result === 'Ok' &&
'data' in value &&
typeof value.data === 'object' &&
value.data !== null
);
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES6",
"module": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"declaration": false,
"types": [
"firefox-webext-browser"
]
},
"include": ["./src/**/*.ts"],
"exclude": ["./node_modules", "dist"]
}