Votre premier appel à l'API en 5 minutes

Créez une intégration OAuth, connectez-vous une fois avec un script prêt à l'emploi, et lisez votre première hiérarchie depuis l'API REST.

Dans cette page

Avant de commencer

Il vous faut :

  • TreeCraft sur un site Jira Cloud, et votre propre compte sur ce site. La personne qui se connecte est celle au nom de qui chaque appel agit, avec son accès à chaque hiérarchie.
  • Les deux interrupteurs activés. Un administrateur du site active l'interrupteur App REST APIs d'Atlassian. L'interrupteur propre à TreeCraft est activé par défaut. Voir Activer l'API.
  • Node.js 18 ou plus récent, que vous pouvez vérifier avec node --version, ou Windows PowerShell 5.1 ou plus récent. Les scripts de cette page n'utilisent rien d'autre.
  • TreeCraft ouvert une fois dans Jira, avec l'accès autorisé. Tant que la personne ne l'a pas fait, les appels répondent 401 REAUTHORIZE.
  • L'identifiant de l'application et celui de l'environnement, qu'un administrateur Jira ou de l'application trouve sur la page API REST de TreeCraft : dans la zone d'administration de Jira, sous Applications, choisissez TreeCraft, puis API REST dans le groupe Accès.

Étape 1 : créer l'intégration

  1. Ouvrez la console des développeurs d'Atlassian à l'adresse developer.atlassian.com/console/myapps, sélectionnez Create, puis OAuth 2.0 integration, et donnez-lui un nom, par exemple « Scripts TreeCraft ».
  2. Sous Settings, copiez le Client ID et le Secret. Le secret fonctionne comme un mot de passe : ne le mettez dans rien que vous partagez.

Étape 2 : ajouter les portées de TreeCraft

  1. Dans l'intégration, ouvrez Permissions et sélectionnez Add Marketplace or custom app. Choisissez votre site, puis TreeCraft.
  2. Ajoutez les portées dont l'intégration a besoin. Commencez par la première, et ajoutez les autres quand un script en a besoin.
Portée Ce qu'elle permet à l'intégration
read:hierarchy:custom Lire les hiérarchies que vous pouvez ouvrir : leurs détails, lignes, règles, paramètres, planification et références, et les vues que vous pouvez utiliser. Ne change rien.
write:hierarchy:custom Dans les hiérarchies que vous pouvez modifier : placer des tickets, des dossiers, des mémos et des éléments de planification, déplacer et retirer des lignes, renommer des dossiers, ajouter des références et marquer des favoris.
manage:hierarchy:custom Créer, renommer, archiver et supprimer les hiérarchies que vous contrôlez, changer leurs règles d'accès, leurs paramètres et leur planification, ajouter et modifier des règles, et gérer vos vues.
read:capacity:custom Lire le calendrier de travail du site et ses jours fériés, les calendriers de jours fériés, et les congés des personnes dont vous pouvez les voir. Ne modifie rien.
write:capacity:custom Ajouter, retirer et remplacer des congés pour les personnes dont vous pouvez les modifier. Les administrateurs Jira et ceux de l'application peuvent aussi modifier les jours fériés et les calendriers de jours fériés.

Ajoutez aussi la portée Jira read:forge-app:jira. Chaque appel à l'API REST d'une application l'exige.

Une portée ne donne jamais plus que ce que vous avez. La liste complète se trouve dans Portées.

Étape 3 : définir l'URL de rappel

Sous Authorization, ajoutez http://localhost:8765/callback comme URL de rappel. Pendant votre connexion, le script Node.js écoute à cette adresse sur votre propre ordinateur, pour que le navigateur lui transmette le code. Le script PowerShell n'écoute pas : la page où arrive le navigateur ne se charge pas, et vous en copiez l'adresse (voir l'étape 5).

Pour utiliser un autre port, changez l'adresse ici et donnez la même valeur à TREECRAFT_CALLBACK.

Étape 4 : donner ses paramètres au script

Les scripts lisent leurs paramètres dans des variables d'environnement : aucun secret n'est écrit dans un fichier que vous pourriez partager.

Variable Valeur
TREECRAFT_CLIENT_ID Le Client ID de l'étape 1.
TREECRAFT_CLIENT_SECRET Le Secret de l'étape 1.
TREECRAFT_APP_ID L'identifiant de l'application, sur la page API REST de TreeCraft.
TREECRAFT_ENVIRONMENT_ID L'identifiant de l'environnement, sur la même page.
TREECRAFT_SITE L'adresse de votre site, comme votre-site.atlassian.net. Le script y lit votre identifiant cloud. Vous pouvez plutôt donner à TREECRAFT_CLOUD_ID l'identifiant cloud affiché sur la page API REST.
TREECRAFT_SCOPES Facultatif. Les portées à demander à la connexion, parmi read, write, manage, capacity-read et capacity-write, séparées par des virgules. Par défaut : read.
TREECRAFT_TOKEN_FILE Facultatif. L'endroit où garder le jeton d'actualisation. Par défaut : treecraft-token.json, dans le dossier d'où vous lancez le script.
TREECRAFT_CALLBACK Facultatif. L'URL de rappel, si vous n'avez pas utilisé http://localhost:8765/callback.

Dans bash (macOS, Linux, ou Git Bash sous Windows), le secret est lu sans s'afficher :

export TREECRAFT_CLIENT_ID='votre-client-id'
read -s -p 'Secret: ' TREECRAFT_CLIENT_SECRET && export TREECRAFT_CLIENT_SECRET
export TREECRAFT_APP_ID='votre-id-application'
export TREECRAFT_ENVIRONMENT_ID='votre-id-environnement'
export TREECRAFT_SITE='votre-site.atlassian.net'

Dans PowerShell :

$env:TREECRAFT_CLIENT_ID = 'votre-client-id'
$env:TREECRAFT_CLIENT_SECRET = [Net.NetworkCredential]::new('', (Read-Host 'Secret' -AsSecureString)).Password
$env:TREECRAFT_APP_ID = 'votre-id-application'
$env:TREECRAFT_ENVIRONMENT_ID = 'votre-id-environnement'
$env:TREECRAFT_SITE = 'votre-site.atlassian.net'

Ces paramètres ne durent que pour cette fenêtre de terminal.

Étape 5 : se connecter

Enregistrez l'un des scripts de Le script pour Node.js ou de Le script pour PowerShell, plus bas, puis connectez-vous une fois :

node treecraft-api.mjs login
.\treecraft-api.ps1 login

Le script affiche une adresse. Ouvrez-la dans votre navigateur, choisissez votre site et sélectionnez Accept. Avec le script Node.js, le navigateur affiche alors « Signed in » et le script poursuit de lui-même. Avec le script PowerShell, la page suivante ne se charge pas : copiez son adresse complète depuis la barre d'adresse du navigateur et collez-la là où le script la demande.

En coulisse, Atlassian envoie un code d'autorisation à l'URL de rappel, et le script l'échange contre deux jetons. Le jeton d'accès a une courte durée de vie, et le script ne l'enregistre jamais. Le jeton d'actualisation est enregistré dans le fichier de jeton, et chaque exécution s'en sert pour obtenir un jeton d'accès tout neuf. C'est le flux OAuth 2.0 (3LO) que Obtenir un jeton d'accès décrit pas à pas.

Atlassian remplace le jeton d'actualisation chaque fois qu'il sert, et le script enregistre le plus récent. Ne lancez donc pas deux copies du script au même moment. Un jeton d'actualisation resté longtemps inutilisé expire : connectez-vous de nouveau.

Le script affiche les portées accordées. Celles de TreeCraft apparaissent sous la forme <id-application>.<id-environnement>:read:hierarchy:custom, et ainsi de suite. Si aucune n'apparaît, l'identifiant de l'application ou celui de l'environnement est faux.

Étape 6 : faire votre premier appel

Listez les hiérarchies que vous pouvez ouvrir :

node treecraft-api.mjs hierarchies
.\treecraft-api.ps1 hierarchies

La réponse ressemble à ceci :

{
  "hierarchies": [
    { "id": 12, "name": "Release plan", "description": "", "ownerAccountId": "5b10ac8d82e05b22cc7d4ef5", "access": "control",
      "archived": false, "version": 48, "labels": ["Planning"], "favorite": true, "searchable": false }
  ],
  "nextCursor": null
}

Voilà tout le chemin : connexion, jeton, appel. Le reste de cette page contient des exemples, les scripts et les raisons de sécurité d'OAuth.

Exemples à copier

Lisez les lignes de la hiérarchie d'identifiant 12, 50 à la fois. Les lignes arrivent sous forme d'identifiants seulement, jamais de contenu de tickets :

node treecraft-api.mjs rows 12 50

Tout autre appel du guide de l'API REST passe par request. Il prend la méthode, le chemin et, pour une modification, un corps JSON écrit sur la ligne de commande ou dans un fichier (@corps.json). Par exemple, pour marquer la hiérarchie comme favorite (cela exige la portée write : connectez-vous de nouveau avec TREECRAFT_SCOPES=read,write). Sous Windows, node peut perdre les guillemets d'un corps JSON tapé dans PowerShell : mettez le corps dans un fichier et écrivez @corps.json, ou utilisez le script PowerShell :

node treecraft-api.mjs request PATCH /v2/hierarchies/12/favorite '{"favorite":true}'
.\treecraft-api.ps1 request PATCH /v2/hierarchies/12/favorite '{"favorite":true}'

Pour ajouter des jours fériés à un calendrier de jours fériés (ici celui d'identifiant 10) et pour ajouter des congés à une personne, connectez-vous de nouveau avec TREECRAFT_SCOPES=read,capacity-read,capacity-write. Le premier exige un administrateur Jira ou de l'application. Le second exige la personne elle-même, un responsable d'une équipe où elle se trouve, ou un administrateur. Les commandes prennent un identifiant de calendrier et des jours, ou un identifiant de compte, le premier jour, le dernier jour (omis : le même jour) et les heures d'absence par jour (omises : des journées entières) :

node treecraft-api.mjs holidays-add 10 2027-12-24 2027-12-31
node treecraft-api.mjs timeoff-add 5b10ac8d82e05b22cc7d4ef5 2027-08-02 2027-08-13
node treecraft-api.mjs timeoff-add 5b10ac8d82e05b22cc7d4ef5 2027-09-03 2027-09-03 4
.\treecraft-api.ps1 holidays-add 10 2027-12-24 2027-12-31
.\treecraft-api.ps1 timeoff-add 5b10ac8d82e05b22cc7d4ef5 2027-08-02 2027-08-13
.\treecraft-api.ps1 timeoff-add 5b10ac8d82e05b22cc7d4ef5 2027-09-03 2027-09-03 4

Ces deux appels sont décrits dans Ajouter des congés et Modifier un calendrier de jours fériés.

Chaque modification porte un en-tête Idempotency-Key que le script fabrique, et un appel auquel Atlassian ou TreeCraft répond 429, 502 ou 503 est retenté après l'attente demandée. Voir Idempotence.

Le script pour Node.js

Enregistrez ceci sous treecraft-api.mjs. Il utilise fetch, http, crypto et fs, intégrés à Node.js, et n'installe rien. Il lit ses paramètres dans les variables d'environnement ci-dessus, ne garde que le jeton d'actualisation dans le fichier de jeton, et n'affiche jamais un jeton ni le secret.

// treecraft-api.mjs: sign in to TreeCraft's REST API and make calls. Needs Node.js 18 or later and nothing else.
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';

const fail = (message) => { console.error(message); process.exit(1); };
if (typeof fetch !== 'function') fail('This script needs Node.js 18 or later.');
const need = (name) => process.env[name] || fail(`Set ${name} first.`);
const CALLBACK = process.env.TREECRAFT_CALLBACK || 'http://localhost:8765/callback';
const TOKEN_FILE = process.env.TREECRAFT_TOKEN_FILE || 'treecraft-token.json';
const SCOPES = {
  read: 'read:hierarchy:custom',
  write: 'write:hierarchy:custom',
  manage: 'manage:hierarchy:custom',
  'capacity-read': 'read:capacity:custom',
  'capacity-write': 'write:capacity:custom',
};

async function tokenRequest(body) {
  const res = await fetch('https://auth.atlassian.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ client_id: need('TREECRAFT_CLIENT_ID'), client_secret: need('TREECRAFT_CLIENT_SECRET'), ...body }),
  });
  const answer = await res.json().catch(() => ({}));
  if (!res.ok) fail(`Atlassian refused the request (${res.status}): ${answer.error || ''} ${answer.error_description || ''}`);
  return answer;
}

function keepRefreshToken(answer) {
  // Atlassian replaces the refresh token each time it is used, so the newest one always goes in the file.
  if (!answer.refresh_token) return;
  fs.writeFileSync(`${TOKEN_FILE}.tmp`, JSON.stringify({ refresh_token: answer.refresh_token }), { mode: 0o600 });
  fs.renameSync(`${TOKEN_FILE}.tmp`, TOKEN_FILE);
}

async function login() {
  const asked = (process.env.TREECRAFT_SCOPES || 'read').split(/[\s,]+/).filter(Boolean);
  const scope = [...asked.map((name) => SCOPES[name] || fail(`Unknown scope "${name}".`)), 'read:forge-app:jira', 'offline_access'].join(' ');
  const state = crypto.randomBytes(16).toString('hex');
  const query = {
    audience: 'api.atlassian.com', client_id: need('TREECRAFT_CLIENT_ID'), scope, redirect_uri: CALLBACK, state,
    response_type: 'code', prompt: 'consent', sns: `${need('TREECRAFT_APP_ID')}.${need('TREECRAFT_ENVIRONMENT_ID')}`,
  };
  console.log('Open this address in your browser, pick your site and select Accept:\n');
  console.log('https://auth.atlassian.com/authorize?' + Object.entries(query).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&'));
  const code = await new Promise((resolve, reject) => {
    const server = http.createServer((req, res) => {
      const url = new URL(req.url, CALLBACK);
      if (url.pathname !== new URL(CALLBACK).pathname) return res.writeHead(404).end();
      const error = url.searchParams.get('error');
      const ok = !error && url.searchParams.get('state') === state && url.searchParams.get('code');
      res.writeHead(ok ? 200 : 400, { 'Content-Type': 'text/plain', Connection: 'close' }).end(ok ? 'Signed in. You can close this tab.' : 'Sign-in failed. See the terminal.');
      server.close();
      if (ok) resolve(url.searchParams.get('code'));
      else reject(new Error(error || 'The answer did not match this sign-in. Start again.'));
    });
    server.listen(Number(new URL(CALLBACK).port), 'localhost');
    setTimeout(() => reject(new Error('Nobody signed in within 5 minutes.')), 300_000).unref();
  }).catch((e) => fail(e.message));
  const answer = await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: CALLBACK });
  keepRefreshToken(answer);
  const granted = answer.scope || '';
  console.log(`\nSigned in. The refresh token is in ${TOKEN_FILE}. Scopes granted:\n${granted}`);
  if (!granted.includes(':custom')) console.log("\nNone of TreeCraft's scopes were granted. Check the app id and environment id.");
}

async function accessToken() {
  if (!fs.existsSync(TOKEN_FILE)) fail('Not signed in yet. Run: node treecraft-api.mjs login');
  const answer = await tokenRequest({ grant_type: 'refresh_token', refresh_token: JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8')).refresh_token });
  keepRefreshToken(answer);
  return answer.access_token;
}

async function baseUrl() {
  let cloudId = process.env.TREECRAFT_CLOUD_ID;
  if (!cloudId) {
    const res = await fetch(`https://${need('TREECRAFT_SITE')}/_edge/tenant_info`);
    cloudId = res.ok ? (await res.json()).cloudId : fail(`Could not read the cloud id from ${process.env.TREECRAFT_SITE} (${res.status}).`);
  }
  return `https://api.atlassian.com/svc/jira/${cloudId}/apps/${need('TREECRAFT_APP_ID')}_${need('TREECRAFT_ENVIRONMENT_ID')}`;
}

async function call(method, path, body) {
  const headers = { Authorization: `Bearer ${await accessToken()}`, Accept: 'application/json' };
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  if (method !== 'GET') headers['Idempotency-Key'] = crypto.randomUUID(); // a retry of the same call reuses it
  const url = (await baseUrl()) + path;
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
    if ([429, 502, 503].includes(res.status) && attempt < 4) {
      const wait = res.headers.get('Retry-After');
      const seconds = Math.min(wait === null ? 2 * attempt : Number(wait) || 0, 30);
      console.error(`Answered ${res.status}. Trying again in ${seconds} seconds.`);
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
      continue;
    }
    const text = await res.text();
    if (!res.ok) console.error(`Answered ${res.status}`);
    try { console.log(JSON.stringify(JSON.parse(text), null, 2)); } catch { if (text) console.log(text); }
    if (!res.ok) process.exit(1);
    return;
  }
}

const id = (value) => (/^\d+$/.test(value || '') ? value : fail('That needs a number, such as 12.'));
const [command, ...args] = process.argv.slice(2);
switch (command) {
  case 'login': await login(); break;
  case 'hierarchies': await call('GET', `/v2/hierarchies?limit=${Number(args[0]) || 100}`); break;
  case 'rows': await call('GET', `/v2/hierarchies/${id(args[0])}/rows?limit=${Number(args[1]) || 1000}`); break;
  case 'holidays-add': await call('PATCH', `/v2/holidaycalendars/${id(args[0])}`, { addDays: args.slice(1) }); break;
  case 'timeoff-add': await call('POST', '/v2/timeoff', { accountId: args[0] || fail('Give an account id.'), timeOff: [{ from: args[1], to: args[2] || args[1], ...(args[3] ? { hours: Number(args[3]) } : {}) }] }); break;
  case 'request': { // request METHOD /v2/path [json | @file.json]
    if (!args[0] || !args[1]?.startsWith('/')) fail('Use: request <METHOD> </v2/path> [json or @file.json]');
    const raw = args[2];
    await call(args[0].toUpperCase(), args[1], raw === undefined ? undefined : JSON.parse(raw.startsWith('@') ? fs.readFileSync(raw.slice(1), 'utf8') : raw));
    break;
  }
  default: fail('Commands: login | hierarchies [limit] | rows <hierarchyId> [limit] | holidays-add <calendarId> <YYYY-MM-DD>... | timeoff-add <accountId> <from> [to] [hours] | request <METHOD> </v2/path> [json or @file.json]');
}

Le fichier de jeton est créé pour votre seul utilisateur là où le système le permet. Gardez-le hors du contrôle de version, et supprimez-le quand vous n'en avez plus besoin : quiconque le détient peut appeler l'API en votre nom jusqu'à ce que l'accès soit révoqué.

Le script pour PowerShell

Enregistrez ceci sous treecraft-api.ps1. Il fonctionne dans Windows PowerShell 5.1 et suivants, n'utilise que ce que PowerShell inclut, et a les mêmes commandes. Si PowerShell refuse d'exécuter des scripts, lancez-le avec powershell -ExecutionPolicy Bypass -File treecraft-api.ps1 hierarchies.

# treecraft-api.ps1: sign in to TreeCraft's REST API and make calls. Needs Windows PowerShell 5.1 or later and nothing else.
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

function Fail($message) { throw $message }
function Setting($name, $default) { $value = [Environment]::GetEnvironmentVariable($name); if ($value) { $value } else { $default } }
function Need($name) { $value = Setting $name $null; if ($value) { $value } else { Fail "Set $name first." } }

$Callback = Setting 'TREECRAFT_CALLBACK' 'http://localhost:8765/callback'
$TokenFile = Setting 'TREECRAFT_TOKEN_FILE' 'treecraft-token.json'
$Scopes = @{
  read = 'read:hierarchy:custom'
  write = 'write:hierarchy:custom'
  manage = 'manage:hierarchy:custom'
  'capacity-read' = 'read:capacity:custom'
  'capacity-write' = 'write:capacity:custom'
}

function Read-ErrorBody($record) { try { $record.ErrorDetails.Message | ConvertFrom-Json } catch { $null } }

function Get-Token($extra) {
  $body = @{ client_id = Need 'TREECRAFT_CLIENT_ID'; client_secret = Need 'TREECRAFT_CLIENT_SECRET' } + $extra
  try {
    Invoke-RestMethod -Method Post -Uri 'https://auth.atlassian.com/oauth/token' -ContentType 'application/json' -Body ($body | ConvertTo-Json)
  } catch {
    $why = Read-ErrorBody $_
    Fail "Atlassian refused the request ($([int]$_.Exception.Response.StatusCode)): $($why.error) $($why.error_description)"
  }
}

function Save-RefreshToken($answer) {
  # Atlassian replaces the refresh token each time it is used, so the newest one always goes in the file.
  if ($answer.refresh_token) { @{ refresh_token = $answer.refresh_token } | ConvertTo-Json | Set-Content -Path $TokenFile -Encoding UTF8 }
}

function Login {
  $scope = @(foreach ($name in ((Setting 'TREECRAFT_SCOPES' 'read') -split '[\s,]+' | Where-Object { $_ })) {
    if ($Scopes[$name]) { $Scopes[$name] } else { Fail "Unknown scope ""$name""." }
  }) + 'read:forge-app:jira', 'offline_access'
  $state = [guid]::NewGuid().ToString('N')
  $query = [ordered]@{
    audience = 'api.atlassian.com'; client_id = Need 'TREECRAFT_CLIENT_ID'; scope = ($scope -join ' '); redirect_uri = $Callback; state = $state
    response_type = 'code'; prompt = 'consent'; sns = "$(Need 'TREECRAFT_APP_ID').$(Need 'TREECRAFT_ENVIRONMENT_ID')"
  }
  Write-Host "Open this address in your browser, pick your site and select Accept:`n"
  Write-Host ('https://auth.atlassian.com/authorize?' + (($query.GetEnumerator() | ForEach-Object { "$($_.Key)=$([uri]::EscapeDataString($_.Value))" }) -join '&'))
  Write-Host "`nThe next page will not load, because nothing listens at $Callback. That is expected."
  $back = (Read-Host 'Copy the whole address of that page from the address bar and paste it here').Trim()
  $answer = @{}
  foreach ($pair in ([uri]$back).Query.TrimStart('?').Split('&')) {
    $i = $pair.IndexOf('=')
    if ($i -gt 0) { $answer[$pair.Substring(0, $i)] = [uri]::UnescapeDataString($pair.Substring($i + 1)) }
  }
  if ($answer['error']) { Fail "Authorization refused: $($answer['error']) $($answer['error_description'])" }
  if ($answer['state'] -ne $state -or -not $answer['code']) { Fail 'The address did not match this sign-in. Start again.' }
  $token = Get-Token @{ grant_type = 'authorization_code'; code = $answer['code']; redirect_uri = $Callback }
  Save-RefreshToken $token
  Write-Host "`nSigned in. The refresh token is in $TokenFile. Scopes granted:`n$($token.scope)"
  if ($token.scope -notmatch ':custom') { Write-Host "`nNone of TreeCraft's scopes were granted. Check the app id and environment id." }
}

function Get-AccessToken {
  if (-not (Test-Path $TokenFile)) { Fail 'Not signed in yet. Run: ./treecraft-api.ps1 login' }
  $answer = Get-Token @{ grant_type = 'refresh_token'; refresh_token = (Get-Content -Raw $TokenFile | ConvertFrom-Json).refresh_token }
  Save-RefreshToken $answer
  $answer.access_token
}

function Get-BaseUrl {
  $cloudId = Setting 'TREECRAFT_CLOUD_ID' $null
  if (-not $cloudId) { $cloudId = (Invoke-RestMethod "https://$(Need 'TREECRAFT_SITE')/_edge/tenant_info").cloudId }
  "https://api.atlassian.com/svc/jira/$cloudId/apps/$(Need 'TREECRAFT_APP_ID')_$(Need 'TREECRAFT_ENVIRONMENT_ID')"
}

function Call($method, $path, $json) {
  $headers = @{ Authorization = "Bearer $(Get-AccessToken)"; Accept = 'application/json' }
  if ($method -ne 'GET') { $headers['Idempotency-Key'] = [guid]::NewGuid().ToString() } # a retry of the same call reuses it
  $request = @{ Method = $method; Uri = (Get-BaseUrl) + $path; Headers = $headers }
  if ($json) { $request.Body = $json; $request.ContentType = 'application/json' }
  for ($attempt = 1; ; $attempt++) {
    try {
      $answer = Invoke-RestMethod @request
      if ($null -ne $answer) { $answer | ConvertTo-Json -Depth 20 }
      return
    } catch {
      $status = [int]$_.Exception.Response.StatusCode
      $why = Read-ErrorBody $_
      if ($status -in 429, 502, 503 -and $attempt -lt 4) {
        $seconds = [Math]::Min($(if ($why.error.retryAfterSeconds) { $why.error.retryAfterSeconds } else { 2 * $attempt }), 30)
        [Console]::Error.WriteLine("Answered $status. Trying again in $seconds seconds.")
        Start-Sleep -Seconds $seconds
        continue
      }
      Fail "Answered $status`n$($_.ErrorDetails.Message)"
    }
  }
}

function Get-Id($value) { if ($value -match '^\d+$') { $value } else { Fail 'That needs a number, such as 12.' } }

function Invoke-TreeCraft($command, $rest) {
  switch ($command) {
    'login' { Login }
    'hierarchies' { Call GET "/v2/hierarchies?limit=$(if ($rest[0]) { [int]$rest[0] } else { 100 })" }
    'rows' { Call GET "/v2/hierarchies/$(Get-Id $rest[0])/rows?limit=$(if ($rest[1]) { [int]$rest[1] } else { 1000 })" }
    'holidays-add' { Call PATCH "/v2/holidaycalendars/$(Get-Id $rest[0])" (@{ addDays = @($rest | Select-Object -Skip 1) } | ConvertTo-Json) }
    'timeoff-add' {
      $entry = @{ from = $rest[1]; to = $(if ($rest[2]) { $rest[2] } else { $rest[1] }) }
      if ($rest[3]) { $entry.hours = [double]$rest[3] }
      Call POST '/v2/timeoff' (@{ accountId = $rest[0]; timeOff = @($entry) } | ConvertTo-Json -Depth 5)
    }
    'request' { # request METHOD /v2/path [json | @file.json]
      if (-not $rest[0] -or -not "$($rest[1])".StartsWith('/')) { Fail 'Use: request <METHOD> </v2/path> [json or @file.json]' }
      $json = $rest[2]; if ($json -and $json.StartsWith('@')) { $json = Get-Content -Raw $json.Substring(1) }
      Call $rest[0].ToUpper() $rest[1] $json
    }
    default { Fail 'Commands: login | hierarchies [limit] | rows <hierarchyId> [limit] | holidays-add <calendarId> <YYYY-MM-DD>... | timeoff-add <accountId> <from> [to] [hours] | request <METHOD> </v2/path> [json or @file.json]' }
  }
}

if ($MyInvocation.InvocationName -ne '.') {
  try { Invoke-TreeCraft $args[0] @($args | Select-Object -Skip 1) } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }
}

Le fichier de jeton est un fichier ordinaire, dans le dossier que vous choisissez. Gardez-le dans un dossier que vous seul pouvez lire.

Si quelque chose ne va pas

Ce que vous voyez Ce qu'il faut vérifier
403 avec « Missing required scopes » L'identifiant de l'application ou celui de l'environnement est faux, ou la portée n'a pas été ajoutée à l'intégration à l'étape 2, ou vous ne l'avez pas demandée à la connexion. Connectez-vous de nouveau.
401 REAUTHORIZE Ouvrez TreeCraft dans Jira une fois et autorisez l'accès, au nom de la personne qui s'est connectée.
403 API_TURNED_OFF Un interrupteur est désactivé : App REST APIs d'Atlassian, ou celui de TreeCraft. Voir Activer l'API.
404 sur une hiérarchie La personne connectée ne peut pas l'ouvrir, ou elle n'existe pas.
« Atlassian refused the request » avec invalid_grant La connexion est épuisée ou a expiré : relancez login.
« Not signed in yet » Lancez d'abord login, depuis le même dossier, ou faites pointer TREECRAFT_TOKEN_FILE vers le fichier.

Les autres réponses sont listées dans Erreurs.

Sécurité : pourquoi OAuth et pas des jetons personnels

L'API de TreeCraft utilise OAuth 2.0 (3LO) avec ses propres portées, plutôt qu'un jeton personnel que n'importe qui pourrait coller dans un script. Il faut quelques étapes de plus pour démarrer, et cela vous protège mieux :

  • Le consentement. La personne qui se connecte voit ce que chaque portée permet à l'intégration, et choisit d'accepter. Le jeton appartient à cette personne, et chaque appel agit en son nom : il est vérifié selon son accès à la hiérarchie et ses permissions Jira, et il ne fait jamais plus que ce qu'elle peut faire.
  • Les portées. Une intégration ne demande que ce dont elle a besoin. Un script qui lit détient un jeton qui ne peut rien modifier.
  • La révocation. La personne peut retirer son consentement depuis son compte Atlassian. Le propriétaire de l'intégration peut changer son secret ou la supprimer dans la console des développeurs. Les jetons d'accès durent peu de temps, et un nouveau exige le jeton d'actualisation.
  • Un interrupteur d'administration. Un administrateur du site peut désactiver l'interrupteur App REST APIs d'Atlassian, et les administrateurs Jira et ceux de l'application peuvent désactiver l'interrupteur propre à TreeCraft. L'un ou l'autre arrête tous les appels d'un coup.
  • Rien à garder pour TreeCraft. Atlassian vérifie le jeton et ses portées avant qu'un appel atteigne l'application, et TreeCraft ne stocke ni jeton ni secret.
  • Vos données restent chez Atlassian. Les appels passent par la passerelle d'Atlassian jusqu'à l'application, qui s'exécute dans le nuage d'Atlassian. L'application n'envoie aucune donnée ailleurs et est conçue selon les exigences Runs on Atlassian d'Atlassian.

Pour votre propre script : gardez le secret du client et le fichier de jeton hors du contrôle de version, ne demandez que les portées que vous utilisez, et utilisez une intégration distincte pour chaque outil, afin de pouvoir en révoquer une sans arrêter les autres.

Des questions sur l’application? Écrivez à info@plugincraft.ca.