Compare commits
3 Commits
1163d2a3f3
...
d7375fa7ae
Author | SHA1 | Date | |
---|---|---|---|
d7375fa7ae | |||
faba9d8b69 | |||
3daab2f3f8 |
58
scripts/set-webook.py
Executable file
58
scripts/set-webook.py
Executable file
@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import re
|
||||
import os
|
||||
import requests
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
|
||||
def die(s):
|
||||
print(s, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
gitlab_token = os.getenv('GITLAB_TOKEN')
|
||||
if gitlab_token is None:
|
||||
die("GITLAB_TOKEN must be set in the environment")
|
||||
|
||||
if len(sys.argv) < 4:
|
||||
die("Usage: {} GITLAB_INSTANCE HOOK_URL_BASE NAMESPACE/REPO\n\nExample: {} gitlab.example.com https://bot.example.com/bebot stuff/myrepo")
|
||||
|
||||
gitlab_instance = sys.argv[1]
|
||||
hook_url = f"{sys.argv[2]}/hooks/gitlab"
|
||||
repo_name = sys.argv[3]
|
||||
|
||||
gl_base = 'https://{}/api/v4/projects/{}/hooks'.format(
|
||||
gitlab_instance,
|
||||
re.sub('/', '%2F', repo_name)
|
||||
)
|
||||
headers = {
|
||||
'authorization': 'Bearer {}'.format(gitlab_token),
|
||||
}
|
||||
payload = {
|
||||
"merge_requests_events": True,
|
||||
"pipeline_events": True,
|
||||
"push_events": True,
|
||||
"tag_push_events": True,
|
||||
}
|
||||
|
||||
resp = requests.get(gl_base, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
resp.raise_for_status()
|
||||
existing = resp.json()
|
||||
|
||||
for hook in existing:
|
||||
if hook['url'] == hook_url:
|
||||
upd_url = '{}/{}'.format(gl_base, hook['id'])
|
||||
resp = requests.put(upd_url, headers=headers, json=payload)
|
||||
print("Updated existing hook")
|
||||
sys.exit(0)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
payload["url"] = hook_url
|
||||
payload["token"] = token
|
||||
resp = requests.post(gl_base, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
print(' "{}/{}":'.format(gitlab_instance, repo_name))
|
||||
print(' token: "{}"'.format(token))
|
213
src/event.rs
213
src/event.rs
@ -1,9 +1,8 @@
|
||||
pub trait GitlabEventExt {
|
||||
fn project(&self) -> &Project;
|
||||
fn r#ref(&self) -> &str;
|
||||
fn r#ref(&self) -> Option<&str>;
|
||||
fn user(&self) -> &str;
|
||||
fn url(&self) -> String;
|
||||
fn title(&self) -> String;
|
||||
fn titles(&self) -> Vec<String>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -70,6 +69,7 @@ impl MergeRequestAction {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergeRequestObjectAttributes {
|
||||
pub iid: u64,
|
||||
pub target_branch: String,
|
||||
pub source_branch: String,
|
||||
pub title: String,
|
||||
@ -109,6 +109,40 @@ pub struct PipelineMergeRequest {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug, Deserialize)]
|
||||
pub enum IssueAction {
|
||||
#[serde(rename = "open")]
|
||||
Opened,
|
||||
#[serde(rename = "close")]
|
||||
Closed,
|
||||
#[serde(rename = "reopen")]
|
||||
Reopened,
|
||||
#[serde(rename = "update")]
|
||||
Updated,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl IssueAction {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
IssueAction::Opened => "opened",
|
||||
IssueAction::Closed => "closed",
|
||||
IssueAction::Reopened => "reopened",
|
||||
IssueAction::Updated => "updated",
|
||||
IssueAction::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IssueObjectAttributes {
|
||||
id: u32,
|
||||
title: String,
|
||||
action: IssueAction,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "object_kind")]
|
||||
pub enum GitlabEvent {
|
||||
@ -148,6 +182,12 @@ pub enum GitlabEvent {
|
||||
commits: Vec<Commit>,
|
||||
total_commits_count: u64,
|
||||
},
|
||||
#[serde(rename = "issue")]
|
||||
Issue {
|
||||
user: User,
|
||||
project: Project,
|
||||
object_attributes: IssueObjectAttributes,
|
||||
},
|
||||
#[serde(rename = "merge_request")]
|
||||
MergeRequest {
|
||||
user: User,
|
||||
@ -158,7 +198,7 @@ pub enum GitlabEvent {
|
||||
#[serde(rename = "pipeline")]
|
||||
Pipeline {
|
||||
object_attributes: PipelineObjectAttributes,
|
||||
merge_request: PipelineMergeRequest,
|
||||
merge_request: Option<PipelineMergeRequest>,
|
||||
user: User,
|
||||
project: Project,
|
||||
},
|
||||
@ -169,17 +209,19 @@ impl GitlabEventExt for GitlabEvent {
|
||||
match self {
|
||||
GitlabEvent::Push { project, .. } => &project,
|
||||
GitlabEvent::TagPush { project, .. } => &project,
|
||||
GitlabEvent::Issue { project, .. } => project,
|
||||
GitlabEvent::MergeRequest { project, .. } => &project,
|
||||
GitlabEvent::Pipeline { project, .. } => &project,
|
||||
}
|
||||
}
|
||||
|
||||
fn r#ref(&self) -> &str {
|
||||
fn r#ref(&self) -> Option<&str> {
|
||||
match self {
|
||||
GitlabEvent::Push { r#ref, .. } => &r#ref,
|
||||
GitlabEvent::TagPush { r#ref, .. } => &r#ref,
|
||||
GitlabEvent::MergeRequest { object_attributes, .. } => &object_attributes.target_branch,
|
||||
GitlabEvent::Pipeline { object_attributes, .. } => &object_attributes.r#ref,
|
||||
GitlabEvent::Push { r#ref, .. } => Some(&r#ref),
|
||||
GitlabEvent::TagPush { r#ref, .. } => Some(&r#ref),
|
||||
GitlabEvent::Issue { .. } => None,
|
||||
GitlabEvent::MergeRequest { object_attributes, .. } => Some(&object_attributes.target_branch),
|
||||
GitlabEvent::Pipeline { object_attributes, .. } => Some(&object_attributes.r#ref),
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,61 +229,128 @@ impl GitlabEventExt for GitlabEvent {
|
||||
match self {
|
||||
GitlabEvent::Push { user_name, .. } => &user_name,
|
||||
GitlabEvent::TagPush { user_name, .. } => &user_name,
|
||||
GitlabEvent::Issue { user, .. } => &user.name,
|
||||
GitlabEvent::MergeRequest { user, .. } => &user.name,
|
||||
GitlabEvent::Pipeline { user, .. } => &user.name,
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self) -> String {
|
||||
let url = match self {
|
||||
GitlabEvent::Push { after, project, .. } => format!("{}/-/commits/{}", project.web_url, after),
|
||||
GitlabEvent::TagPush {
|
||||
r#ref,
|
||||
checkout_sha,
|
||||
project,
|
||||
..
|
||||
} => {
|
||||
let refname = r#ref.split('/').into_iter().last().unwrap_or(checkout_sha);
|
||||
format!("{}/-/tags/{}", project.web_url, refname)
|
||||
}
|
||||
GitlabEvent::MergeRequest { object_attributes, .. } => object_attributes.url.clone(),
|
||||
GitlabEvent::Pipeline { object_attributes, .. } => object_attributes.url.clone(),
|
||||
};
|
||||
|
||||
url.replace("http://", "https://").to_string()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
fn titles(&self) -> Vec<String> {
|
||||
fn find_commit<'a>(commits: &'a Vec<Commit>, sha: &str) -> Option<&'a Commit> {
|
||||
commits.iter().find(|commit| commit.id == sha)
|
||||
}
|
||||
|
||||
match self {
|
||||
GitlabEvent::Push { after, commits, .. } => find_commit(commits, &after)
|
||||
.map(|commit| commit.title.clone())
|
||||
.unwrap_or_else(|| "New commit(s) pushed".to_string()),
|
||||
GitlabEvent::Push {
|
||||
after,
|
||||
project,
|
||||
commits,
|
||||
..
|
||||
} => {
|
||||
const MAX_COMMITS: usize = 15; // TODO: make configurable
|
||||
commits.iter().fold(Vec::new(), |mut titles, commit| {
|
||||
if titles.len() < MAX_COMMITS {
|
||||
titles.push(format!("[**pushed** {}]({})", commit.title, commit.url));
|
||||
|
||||
if titles.len() == MAX_COMMITS {
|
||||
titles.push(format!(
|
||||
"[**pushed** {} more commits]({}/-/compare/{}...{})",
|
||||
commits.len() - MAX_COMMITS,
|
||||
project.web_url,
|
||||
commit.id,
|
||||
after
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
titles
|
||||
})
|
||||
}
|
||||
GitlabEvent::TagPush {
|
||||
checkout_sha, commits, ..
|
||||
} => find_commit(commits, &checkout_sha)
|
||||
.iter()
|
||||
.fold("New tag pushed".to_string(), |accum, commit| {
|
||||
format!("{} ({})", accum, commit.title)
|
||||
}),
|
||||
r#ref,
|
||||
checkout_sha,
|
||||
project,
|
||||
commits,
|
||||
..
|
||||
} => {
|
||||
let title = format!(
|
||||
"**tagged** {}",
|
||||
find_commit(commits, &checkout_sha)
|
||||
.map(|commit| &commit.title)
|
||||
.unwrap_or(checkout_sha)
|
||||
);
|
||||
let url = format!("{}/-/tags/{}", project.web_url, parse_ref(r#ref));
|
||||
vec![markdown_link(&title, &url)]
|
||||
}
|
||||
GitlabEvent::Issue { object_attributes, .. } => {
|
||||
if object_attributes.action != IssueAction::Other {
|
||||
let title = format!(
|
||||
"Issue #{} **{}**: {}",
|
||||
object_attributes.id,
|
||||
object_attributes.action.as_str(),
|
||||
object_attributes.title
|
||||
);
|
||||
vec![markdown_link(&title, &object_attributes.url)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
GitlabEvent::MergeRequest { object_attributes, .. } => {
|
||||
format!("MR {}: {}", object_attributes.action.as_str(), object_attributes.title)
|
||||
if object_attributes.action != MergeRequestAction::Other {
|
||||
let title = format!(
|
||||
"MR !{} **{}**: {}",
|
||||
object_attributes.iid,
|
||||
object_attributes.action.as_str(),
|
||||
object_attributes.title
|
||||
);
|
||||
vec![markdown_link(&title, &object_attributes.url)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
GitlabEvent::Pipeline {
|
||||
object_attributes,
|
||||
merge_request,
|
||||
..
|
||||
} => {
|
||||
let title = object_attributes.name.as_ref().unwrap_or(&merge_request.title);
|
||||
format!("Pipeline {}: {}", object_attributes.status.as_str(), title)
|
||||
if object_attributes.status != PipelineStatus::Other {
|
||||
let title = object_attributes
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|n| n.clone())
|
||||
.or(merge_request.as_ref().map(|mr| mr.title.clone()))
|
||||
.iter()
|
||||
.fold(
|
||||
format!("Pipeline **{}**", object_attributes.status.as_str()),
|
||||
|accum, title| format!("{}: {}", accum, title),
|
||||
);
|
||||
vec![markdown_link(&title, &object_attributes.url)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn markdown_link(title: &String, url: &String) -> String {
|
||||
format!("[{}]({})", title, url)
|
||||
}
|
||||
|
||||
pub fn parse_ref(r#ref: &str) -> String {
|
||||
if r#ref.starts_with("refs/") {
|
||||
let parts = r#ref.split('/').collect::<Vec<_>>();
|
||||
if parts.len() > 2 {
|
||||
parts.into_iter().skip(2).collect::<Vec<_>>().join("/").to_string()
|
||||
} else {
|
||||
r#ref.to_string()
|
||||
}
|
||||
} else {
|
||||
r#ref.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
@ -323,6 +432,26 @@ mod test {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn parse_issue_event() -> anyhow::Result<()> {
|
||||
let event = load_test_data("issue-event")?;
|
||||
|
||||
match event {
|
||||
GitlabEvent::Issue {
|
||||
user,
|
||||
object_attributes,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(user.name, "Administrator");
|
||||
assert_eq!(object_attributes.id, 301);
|
||||
assert_eq!(object_attributes.action, IssueAction::Opened);
|
||||
}
|
||||
_ => panic!("not an issue event"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn parse_merge_request_event() -> anyhow::Result<()> {
|
||||
let event = load_test_data("merge-request-event")?;
|
||||
@ -357,7 +486,7 @@ mod test {
|
||||
assert_eq!(object_attributes.name, Some("Pipeline for branch: master".to_string()));
|
||||
assert_eq!(object_attributes.r#ref, "master");
|
||||
assert_eq!(object_attributes.status, PipelineStatus::Failed);
|
||||
assert_eq!(merge_request.title, "Test");
|
||||
assert_eq!(merge_request.unwrap().title, "Test");
|
||||
assert_eq!(user.name, "Administrator");
|
||||
}
|
||||
_ => panic!("not a pipeline event"),
|
||||
|
60
src/main.rs
60
src/main.rs
@ -23,8 +23,6 @@ use matrix_sdk::{
|
||||
use tokio::sync::mpsc;
|
||||
use warp::Filter;
|
||||
|
||||
use crate::event::{MergeRequestAction, PipelineStatus};
|
||||
|
||||
async fn build_sync_settings(matrix_client: &Client) -> SyncSettings {
|
||||
let mut settings = SyncSettings::default().timeout(Duration::from_secs(30));
|
||||
if let Some(token) = matrix_client.sync_token().await {
|
||||
@ -105,28 +103,25 @@ async fn ensure_matrix_room_joined(matrix_client: &Client, room_id: &OwnedRoomOr
|
||||
room.ok_or_else(|| anyhow!("Unable to join room {}", room_id))
|
||||
}
|
||||
|
||||
fn build_gitlab_message(event: &GitlabEvent) -> String {
|
||||
fn build_gitlab_messages(event: &GitlabEvent) -> Vec<String> {
|
||||
let project = event.project();
|
||||
let r = event.r#ref();
|
||||
let refname = if r.starts_with("refs/") {
|
||||
let parts = r.split('/').collect::<Vec<_>>();
|
||||
if parts.len() > 2 {
|
||||
parts.into_iter().skip(2).collect::<Vec<_>>().join("/").to_string()
|
||||
} else {
|
||||
drop(parts);
|
||||
r.to_string()
|
||||
}
|
||||
} else {
|
||||
r.to_string()
|
||||
};
|
||||
format!(
|
||||
"*{}* {} **{}** [{}]({})",
|
||||
project.path_with_namespace,
|
||||
refname,
|
||||
event.user(),
|
||||
event.title(),
|
||||
event.url()
|
||||
)
|
||||
let refname = event.r#ref().map(event::parse_ref);
|
||||
event
|
||||
.titles()
|
||||
.iter()
|
||||
.map(|title| {
|
||||
format!(
|
||||
"\\[{}\\] {}*{}* {}",
|
||||
project.path_with_namespace,
|
||||
refname
|
||||
.as_ref()
|
||||
.map(|rn| format!("`{}` ", rn))
|
||||
.unwrap_or_else(|| "".to_string()),
|
||||
event.user(),
|
||||
title,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn handle_gitlab_event(
|
||||
@ -134,21 +129,12 @@ async fn handle_gitlab_event(
|
||||
room_id: &OwnedRoomOrAliasId,
|
||||
matrix_client: &Client,
|
||||
) -> anyhow::Result<()> {
|
||||
if let GitlabEvent::MergeRequest { object_attributes, .. } = &event {
|
||||
if object_attributes.action == MergeRequestAction::Other {
|
||||
return Ok(());
|
||||
}
|
||||
} else if let GitlabEvent::Pipeline { object_attributes, .. } = &event {
|
||||
if object_attributes.status == PipelineStatus::Other {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let room = ensure_matrix_room_joined(matrix_client, room_id).await?;
|
||||
let msg = build_gitlab_message(&event);
|
||||
debug!("Sending message to {}: {}", room_id, msg);
|
||||
let msg_content = RoomMessageEventContent::text_markdown(&msg);
|
||||
room.send(msg_content, None).await?;
|
||||
for msg in build_gitlab_messages(&event) {
|
||||
debug!("Sending message to {}: {}", room_id, msg);
|
||||
let msg_content = RoomMessageEventContent::text_markdown(&msg);
|
||||
room.send(msg_content, None).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
145
test-data/issue-event.json
Normal file
145
test-data/issue-event.json
Normal file
@ -0,0 +1,145 @@
|
||||
{
|
||||
"object_kind": "issue",
|
||||
"event_type": "issue",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"name": "Administrator",
|
||||
"username": "root",
|
||||
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
"project": {
|
||||
"id": 1,
|
||||
"name":"Gitlab Test",
|
||||
"description":"Aut reprehenderit ut est.",
|
||||
"web_url":"http://example.com/gitlabhq/gitlab-test",
|
||||
"avatar_url":null,
|
||||
"git_ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
|
||||
"git_http_url":"http://example.com/gitlabhq/gitlab-test.git",
|
||||
"namespace":"GitlabHQ",
|
||||
"visibility_level":20,
|
||||
"path_with_namespace":"gitlabhq/gitlab-test",
|
||||
"default_branch":"master",
|
||||
"ci_config_path": null,
|
||||
"homepage":"http://example.com/gitlabhq/gitlab-test",
|
||||
"url":"http://example.com/gitlabhq/gitlab-test.git",
|
||||
"ssh_url":"git@example.com:gitlabhq/gitlab-test.git",
|
||||
"http_url":"http://example.com/gitlabhq/gitlab-test.git"
|
||||
},
|
||||
"object_attributes": {
|
||||
"id": 301,
|
||||
"title": "New API: create/update/delete file",
|
||||
"assignee_ids": [51],
|
||||
"assignee_id": 51,
|
||||
"author_id": 51,
|
||||
"project_id": 14,
|
||||
"created_at": "2013-12-03T17:15:43Z",
|
||||
"updated_at": "2013-12-03T17:15:43Z",
|
||||
"updated_by_id": 1,
|
||||
"last_edited_at": null,
|
||||
"last_edited_by_id": null,
|
||||
"relative_position": 0,
|
||||
"description": "Create new API for manipulations with repository",
|
||||
"milestone_id": null,
|
||||
"state_id": 1,
|
||||
"confidential": false,
|
||||
"discussion_locked": true,
|
||||
"due_date": null,
|
||||
"moved_to_id": null,
|
||||
"duplicated_to_id": null,
|
||||
"time_estimate": 0,
|
||||
"total_time_spent": 0,
|
||||
"time_change": 0,
|
||||
"human_total_time_spent": null,
|
||||
"human_time_estimate": null,
|
||||
"human_time_change": null,
|
||||
"weight": null,
|
||||
"health_status": "at_risk",
|
||||
"iid": 23,
|
||||
"url": "http://example.com/diaspora/issues/23",
|
||||
"state": "opened",
|
||||
"action": "open",
|
||||
"severity": "high",
|
||||
"escalation_status": "triggered",
|
||||
"escalation_policy": {
|
||||
"id": 18,
|
||||
"name": "Engineering On-call"
|
||||
},
|
||||
"labels": [{
|
||||
"id": 206,
|
||||
"title": "API",
|
||||
"color": "#ffffff",
|
||||
"project_id": 14,
|
||||
"created_at": "2013-12-03T17:15:43Z",
|
||||
"updated_at": "2013-12-03T17:15:43Z",
|
||||
"template": false,
|
||||
"description": "API related issues",
|
||||
"type": "ProjectLabel",
|
||||
"group_id": 41
|
||||
}]
|
||||
},
|
||||
"repository": {
|
||||
"name": "Gitlab Test",
|
||||
"url": "http://example.com/gitlabhq/gitlab-test.git",
|
||||
"description": "Aut reprehenderit ut est.",
|
||||
"homepage": "http://example.com/gitlabhq/gitlab-test"
|
||||
},
|
||||
"assignees": [{
|
||||
"name": "User1",
|
||||
"username": "user1",
|
||||
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
|
||||
}],
|
||||
"assignee": {
|
||||
"name": "User1",
|
||||
"username": "user1",
|
||||
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
|
||||
},
|
||||
"labels": [{
|
||||
"id": 206,
|
||||
"title": "API",
|
||||
"color": "#ffffff",
|
||||
"project_id": 14,
|
||||
"created_at": "2013-12-03T17:15:43Z",
|
||||
"updated_at": "2013-12-03T17:15:43Z",
|
||||
"template": false,
|
||||
"description": "API related issues",
|
||||
"type": "ProjectLabel",
|
||||
"group_id": 41
|
||||
}],
|
||||
"changes": {
|
||||
"updated_by_id": {
|
||||
"previous": null,
|
||||
"current": 1
|
||||
},
|
||||
"updated_at": {
|
||||
"previous": "2017-09-15 16:50:55 UTC",
|
||||
"current": "2017-09-15 16:52:00 UTC"
|
||||
},
|
||||
"labels": {
|
||||
"previous": [{
|
||||
"id": 206,
|
||||
"title": "API",
|
||||
"color": "#ffffff",
|
||||
"project_id": 14,
|
||||
"created_at": "2013-12-03T17:15:43Z",
|
||||
"updated_at": "2013-12-03T17:15:43Z",
|
||||
"template": false,
|
||||
"description": "API related issues",
|
||||
"type": "ProjectLabel",
|
||||
"group_id": 41
|
||||
}],
|
||||
"current": [{
|
||||
"id": 205,
|
||||
"title": "Platform",
|
||||
"color": "#123123",
|
||||
"project_id": 14,
|
||||
"created_at": "2013-12-03T17:15:43Z",
|
||||
"updated_at": "2013-12-03T17:15:43Z",
|
||||
"template": false,
|
||||
"description": "Platform related issues",
|
||||
"type": "ProjectLabel",
|
||||
"group_id": 41
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user