Comment passer à l'échelle grâce à l'automatisation complète en combinant deux fonctionnalités Asana :

Asana Studio et les Script Actions

(prompt & script inclus)

Ce tutoriel vous guide pas à pas dans la mise en place d’un workflow complet combinant AI Studio et les Script Actions pour automatiser le staffing et la création de projets dans Asana. Il accompagne notre article de blog sur le sujet, où vous trouverez le contexte complet et les cas d’usage associés

⚠️ Avertissement : Certaines parties de ce guide s’adressent à des utilisateurs avancés.

Comment le mettre en œuvre (prompt & script inclus)

Prérequis

Vous aurez besoin de :

  • Accès à AI Studio
  • (Optionnel) Accès aux Script Actions
  • Un projet d’intake (ex. : « Requests ») avec :
    • Un champ de date d’échéance
    • Un champ personnalisé de type Personnes par rôle (Chef de projet, Spécialiste, etc.)
    • Des sections telles que « Pending assignment », « Staffing », « Create project with roles »…
  • Un modèle de projet représentant le travail type pour une demande, avec :
    • Des rôles de modèle définis correspondant à vos champs Personnes
    • Des tâches du modèle assignées aux rôles

 

Étape par étape

Étape 1 – Configurer le workflow d’intake

  1. Créez ou réutilisez le projet d’intake et son formulaire.
  2. Ajoutez les champs personnalisés de type Personnes pour chaque rôle à pourvoir.
  3. Configurez les sections que vous utiliserez pour suivre l’avancement.

 

Étape 2 – Créer l’agent de staffing dans AI Studio

  1. Déclenchez-le via une règle lorsqu’une nouvelle demande est créée ou mise à jour.
  2. Dans les instructions de l’agent, demandez-lui de :
  3. Filtrer l’équipe par rôle (et éventuellement par compétences).
  4. Utiliser une vue de charge de travail / capacité pour éviter de surcharger les personnes.
  5. Choisir 1 personne par rôle et rédiger une courte explication dans un commentaire.
  6. Remplir les champs Personnes sur la tâche.
  7. Déplacer la tâche vers :
    • « Create project with roles (by Script Actions) » lorsque tous les rôles sont pourvus, ou
    • « Pending assignment » si quelque chose manque ou est ambigu.

 

(Le prompt AI Studio et le code de la Script Action restent en anglais – voir la description de la tâche.)

Vous êtes un assistant de staffing Asana pour les demandes informatiques (IT). **Mission** Remplissez les DEUX champs personnalisés de type « Personnes » sur la tâche actuelle : - 🧑‍🍳 Chef de projet [IT] - 🧑‍💻 Ingénieur technique [IT] **Comment choisir les intervenants** 1) Ouvrez la page de l'équipe IT et identifiez les candidats via le champ « Rôle [IT] » : - Pour le CP : choisissez parmi les personnes dont le Rôle [IT] = Project Manager. - Pour le Tech : choisissez parmi les personnes dont le Rôle [IT] = Technical Engineer. Page de l'équipe : IT (https://app.asana.com/0/1202805238464599/1209002045072288) 2) Ouvrez la page de charge de travail (Workload) IT et choisissez, parmi les candidats, la personne ayant la plus grande CAPACITÉ durant la période de la tâche (date de début et échéance). Page de charge de travail : [IT] Team Workload (https://app.asana.com/0/1212848555821367/1212813398026822) **Règles** - Si la tâche n'a pas d'échéance → n'attribuez personne. - Si vous ne trouvez pas de personne valide pour un rôle → laissez le champ vide. - N'attribuez quelqu'un que si son Rôle [IT] correspond exactement au rôle requis. - Évitez d'attribuer la même personne aux deux rôles, sauf si elle correspond clairement aux deux ET qu'elle dispose de la capacité nécessaire. **Après la tentative de staffing (ajoutez TOUJOURS un commentaire)** Publiez un commentaire bien mis en forme avec des emojis incluant : - 📅 Échéance (ou « échéance manquante ») - 🧑‍🍳 CP choisi (nom) + raison courte (« Rôle correspondant + capacité maximale ») - 🧑‍💻 Tech choisi (nom) + raison courte (« Rôle correspondant + capacité maximale ») - 🔎 Pages utilisées : Équipe + Charge de travail - ✅ Résultat : « Staffing complet » OU « Attribution(s) manquante(s) : ... » **Déplacez la tâche selon le résultat** - Si les DEUX rôles sont remplis → déplacez la tâche vers la section : « 🤖 Créer projet avec rôles (via Script Actions) » - Si l'un ou les deux rôles ne sont pas remplis → déplacez la tâche vers la section : « En attente d'attribution »

Étape 3 – Créer la Script Action qui génère le projet

  1. Déclenchez-la lorsqu’une tâche entre dans la section « Create project with roles ».
  2. Dans le script, vous :
  3. Lisez la tâche et ses champs personnalisés de type Personnes.
  4. Appelez instantiateProject(...) sur votre modèle, en passant :
  5. Un nom de projet généré
  6. Un mapping requested_roles des rôles du modèle vers les identifiants utilisateurs issus des champs Personnes
  7. (Optionnel) requested_dates si votre modèle utilise des variables de date (par exemple, début = aujourd’hui, échéance = date d’échéance de la demande)
  8. L’équipe, si nécessaire dans votre espace de travail

Avertissement : Ce script a été conçu pour notre propre environnement et nécessite une mise à jour des variables (GID) pour fonctionner dans le vôtre. Si vous n’êtes pas familier avec ces aspects techniques, la configuration peut s’avérer complexe. N’hésitez pas à solliciter un assistant IA pour vous guider dans l’adaptation de votre script. :wink:

// Code created by Asana Solutions Partner i.DO (ido-clarity.com). async function run() { log('=== SCRIPT START: Instantiate project from template + link back to request ==='); log(`workspace_gid: ${workspace_gid}`); log(`project_gid: ${project_gid}`); log(`task_gid: ${task_gid}`); const projectTemplateGid = '1207291537152711'; // Software update template // People custom fields on the triggering task const PM_PEOPLE_CF_GID = '1212848577913693'; // 🧑‍🍳Project Manager [IT] const TECH_PEOPLE_CF_GID = '1212848580115291'; // 🧑‍💻Technical Engineer [IT] // Template role names (as they exist on the template) const ROLE_PM_NAME = 'Project Manager'; const ROLE_TECH_NAME = 'Technical Engineer'; const TASK_PREFIX = '[Initial request - converted to project] '; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function getFirstPersonFromPeopleCustomField(task, customFieldGid) { const cf = (task.custom_fields || []).find((x) => x.gid === customFieldGid); if (!cf) return null; const pv = cf.people_value; if (!pv || !Array.isArray(pv) || pv.length === 0) return null; return pv[0]; } // --- Helpers for color & icon selection --- function pickColorFromProjectName(name) { const palette = [ 'dark-blue', 'dark-green', 'dark-pink', 'dark-purple', 'dark-red', 'dark-teal', 'dark-orange', 'dark-brown' ]; if (!name || typeof name !== 'string') { log('pickColorFromProjectName: no name provided, defaulting to dark-blue'); return 'dark-blue'; } const trimmed = name.trim(); if (!trimmed) { log('pickColorFromProjectName: empty name after trim, defaulting to dark-blue'); return 'dark-blue'; } const firstLetter = trimmed[0].toUpperCase(); const code = firstLetter.charCodeAt(0); if (code < 65 || code > 90) { log(`pickColorFromProjectName: first char "${firstLetter}" is not A-Z, defaulting to dark-blue`); return 'dark-blue'; } const index = (code - 65) % palette.length; const color = palette[index]; log(`pickColorFromProjectName: name="${name}", firstLetter="${firstLetter}", color="${color}"`); return color; } function pickIconFromProjectName(name) { const lower = (name || '').toLowerCase(); const mappings = [ { regex: /(security|secur|auth|password|2fa|mfa)/, icon: 'shield' }, { regex: /(bug|incident|issue|defect|error)/, icon: 'bug' }, { regex: /(infra|server|hosting|cloud|aws|gcp|azure)/, icon: 'cloud' }, { regex: /(deploy|release|launch|rollout|go live|golive)/, icon: 'rocket' }, { regex: /(data|report|kpi|metric|analytics|dashboard)/, icon: 'graph' } ]; for (const m of mappings) { if (m.regex.test(lower)) { log(`pickIconFromProjectName: matched "${m.regex}" for name="${name}", icon="${m.icon}"`); return m.icon; } } log(`pickIconFromProjectName: no thematic match for name="${name}", defaulting to "star"`); return 'star'; } // --- Helpers for errors: move task + comment --- async function moveTaskToPendingAssignmentSection(taskGid, projectGid) { if (typeof sectionsApiInstance === 'undefined') { log('sectionsApiInstance is not available; cannot move task to "Pending Assignment".'); return; } log(`Looking for section "Pending Assignment" in project ${projectGid}...`); const rawSections = await sectionsApiInstance.getSectionsForProject(projectGid, { opt_fields: 'gid,name' }); const sections = rawSections && rawSections.data ? rawSections.data : rawSections; if (!Array.isArray(sections)) { log('getSectionsForProject returned an unexpected payload; cannot move task.'); return; } const targetSection = sections.find( (s) => (s.name || '').trim().toLowerCase() === 'pending assignment' ); if (!targetSection) { log('Section "Pending Assignment" was not found; task will not be moved.'); return; } log(`Moving task ${taskGid} to section "${targetSection.name}" (${targetSection.gid})...`); // Script Actions env: section_gid first, then opts.body.data const addOpts = { body: { data: { task: taskGid } } }; await sectionsApiInstance.addTaskForSection(targetSection.gid, addOpts); log('Task moved back to "Pending Assignment" section.'); } async function addErrorCommentToTask(taskGid, message) { if (typeof storiesApiInstance === 'undefined') { log('storiesApiInstance is not available; cannot add an error comment.'); return; } const text = '❌ Project not created by script\n\n' + message; log('Adding error comment to the request task: ' + text.replace(/\n/g, ' ')); await storiesApiInstance.createStoryForTask( { data: { text } }, taskGid ); log('Error comment added successfully.'); } // 1) Load triggering task with needed fields log('Fetching triggering task with People custom fields + due dates...'); const rawTaskResp = await tasksApiInstance.getTask(task_gid, { opt_fields: 'name,due_on,due_at,custom_fields.gid,custom_fields.name,custom_fields.resource_subtype,custom_fields.people_value.gid,custom_fields.people_value.name' }); const task = rawTaskResp && rawTaskResp.data ? rawTaskResp.data : rawTaskResp; log(`Triggering task loaded: "${task.name}" (${task_gid})`); const pmPerson = getFirstPersonFromPeopleCustomField(task, PM_PEOPLE_CF_GID); const techPerson = getFirstPersonFromPeopleCustomField(task, TECH_PEOPLE_CF_GID); log(`🧑‍🍳 PM field -> ${pmPerson ? `${pmPerson.name} (${pmPerson.gid})` : 'NOT SET'}`); log(`🧑‍💻 TECH field -> ${techPerson ? `${techPerson.name} (${techPerson.gid})` : 'NOT SET'}`); if (!pmPerson || !techPerson) { log('STOP: Both roles must be filled on the task before creating the project from template.'); const errorMessage = 'Both roles must be filled on this request before a project can be created from the template. ' + 'Please set both 🧑‍🍳 Project Manager [IT] and 🧑‍💻 Technical Engineer [IT], then re-run the rule. ' + 'The task was moved back to the "Pending Assignment" section.'; await moveTaskToPendingAssignmentSection(task_gid, project_gid); await addErrorCommentToTask(task_gid, errorMessage); log('=== SCRIPT END (no instantiation - missing roles) ==='); return; } // 2) Fetch workspace to know if it is an organization (team vs workspace payload differences) log('Fetching workspace to check is_organization...'); const rawWsResp = await workspacesApiInstance.getWorkspace(workspace_gid, { opt_fields: 'name,is_organization' }); const ws = rawWsResp && rawWsResp.data ? rawWsResp.data : rawWsResp; log(`Workspace: "${ws.name}", is_organization: ${ws.is_organization}`); // 3) Fetch project template details (roles + date variables + team) log('Fetching project template (requested_roles + requested_dates + team)...'); const rawTmplResp = await projectTemplatesApiInstance.getProjectTemplate(projectTemplateGid, { opt_fields: 'name,team.gid,team.name,requested_roles.gid,requested_roles.name,requested_dates.gid,requested_dates.name,requested_dates.description' }); const template = rawTmplResp && rawTmplResp.data ? rawTmplResp.data : rawTmplResp; log(`Template: "${template.name}" (${projectTemplateGid})`); if (template.team) { log(`Template team: ${template.team.name} (${template.team.gid})`); } else { log('Template team: none'); } const requestedRolesFromTemplate = template.requested_roles || []; log(`Template requested_roles count: ${requestedRolesFromTemplate.length}`); for (const r of requestedRolesFromTemplate) { log(`ROLE -> "${r.name}" (${r.gid})`); } const roleGidByName = {}; for (const r of requestedRolesFromTemplate) roleGidByName[r.name] = r.gid; const pmRoleGid = roleGidByName[ROLE_PM_NAME]; const techRoleGid = roleGidByName[ROLE_TECH_NAME]; if (!pmRoleGid || !techRoleGid) { log('STOP: Could not find role GIDs on the template by expected role names.'); log(`Expected role "${ROLE_PM_NAME}" -> ${pmRoleGid || 'NOT FOUND'}`); log(`Expected role "${ROLE_TECH_NAME}" -> ${techRoleGid || 'NOT FOUND'}`); const errorMessage = 'The script could not find one or both expected roles ("Project Manager" / "Technical Engineer") on the project template. ' + 'Please check that both roles exist on the template and are correctly named, then re-run the rule.'; await addErrorCommentToTask(task_gid, errorMessage); log('=== SCRIPT END (no instantiation - template roles missing) ==='); return; } // 4) Build requested_dates (if template uses date variables) const todayStr = new Date().toISOString().slice(0, 10); const taskDueStr = task.due_on ? task.due_on : task.due_at ? String(task.due_at).slice(0, 10) : null; log(`Today (UTC date): ${todayStr}`); log(`Task due date (best effort): ${taskDueStr || 'none'}`); const requestedDatesFromTemplate = template.requested_dates || []; log(`Template requested_dates count: ${requestedDatesFromTemplate.length}`); const requested_dates = []; for (const d of requestedDatesFromTemplate) { const name = (d.name || '').toLowerCase(); // Lightweight heuristic: // - "start/kickoff" -> today // - "due/end/launch" -> task due date if available (else today) let value = todayStr; if (taskDueStr && /(due|end|finish|deadline|launch|go live|golive)/.test(name)) { value = taskDueStr; } requested_dates.push({ gid: d.gid, value }); log(`DATE VAR -> "${d.name}" (${d.gid}) => value: ${value}`); } // 5) Build requested_roles const requested_roles = [ { gid: pmRoleGid, value: pmPerson.gid }, { gid: techRoleGid, value: techPerson.gid } ]; log(`Mapping role "${ROLE_PM_NAME}" (${pmRoleGid}) -> ${pmPerson.name} (${pmPerson.gid})`); log(`Mapping role "${ROLE_TECH_NAME}" (${techRoleGid}) -> ${techPerson.name} (${techPerson.gid})`); // 6) Build instantiation payload const projectName = `${template.name} - ${task.name}`; const instantiateData = { name: projectName, requested_roles }; if (requested_dates.length > 0) { instantiateData.requested_dates = requested_dates; } if (ws.is_organization) { if (template.team?.gid) { instantiateData.team = template.team.gid; log(`Including team in payload (org workspace): ${template.team.name} (${template.team.gid})`); } else { log('WARNING: Workspace is org but template.team is missing. Not setting team in payload.'); } } else { instantiateData.workspace = workspace_gid; log(`Including workspace in payload (non-org workspace): ${workspace_gid}`); } const instantiateOpts = { body: { data: instantiateData } }; log(`Instantiating project with name: ${projectName}`); log('instantiateProject body: ' + JSON.stringify(instantiateOpts.body)); // 7) Instantiate project (returns a Job) const rawJobResponse = await projectTemplatesApiInstance.instantiateProject(projectTemplateGid, instantiateOpts); const jobResponse = rawJobResponse && rawJobResponse.data ? rawJobResponse.data : rawJobResponse; log('InstantiateProject job response: ' + JSON.stringify(jobResponse)); const job = jobResponse || null; if (!job?.gid) { log('No job gid returned. Cannot poll job.'); const errorMessage = 'The project template instantiation did not return a job identifier, so the script could not complete project creation. ' + 'Please try running the rule again; if the problem persists, check the template configuration.'; await addErrorCommentToTask(task_gid, errorMessage); log('=== SCRIPT END (no instantiation - missing job gid) ==='); return; } log(`Job gid: ${job.gid}, status: ${job.status || 'unknown'}`); // 8) Poll job to get the created project let newProjectGid = null; let newProjectName = null; let newProjectUrl = null; if (typeof jobsApiInstance === 'undefined') { log('jobsApiInstance is not available; cannot poll for new_project.'); } else { for (let i = 1; i <= 12; i++) { const rawJobCheck = await jobsApiInstance.getJob(job.gid, { opt_fields: 'status,new_project.gid,new_project.name,new_project.permalink_url' }); const j = rawJobCheck && rawJobCheck.data ? rawJobCheck.data : rawJobCheck; log(`Poll #${i}: status=${j.status}`); if (j.new_project?.gid) { newProjectGid = j.new_project.gid; newProjectName = j.new_project.name; newProjectUrl = j.new_project.permalink_url || null; log(`✅ New project created: ${newProjectName} (${newProjectGid})`); log(`Project URL (from job): ${newProjectUrl || '(none returned)'}`); break; } await sleep(1000); } } // Best effort: if URL missing but we have project gid, fetch permalink_url if (newProjectGid && !newProjectUrl && typeof projectsApiInstance !== 'undefined') { log('Fetching new project to get permalink_url...'); const rawProjResp = await projectsApiInstance.getProject(newProjectGid, { opt_fields: 'name,permalink_url,color,icon' }); const projResp = rawProjResp && rawProjResp.data ? rawProjResp.data : rawProjResp; newProjectName = projResp?.name || newProjectName; newProjectUrl = projResp?.permalink_url || null; log(`Project URL (from project): ${newProjectUrl || '(none returned)'}`); } // If we still don't have the created project, stop here if (!newProjectGid) { log('Could not retrieve new_project from job polling. Skipping prefix/comment/multi-home.'); const errorMessage = 'The script could not retrieve the new project after instantiation. ' + 'The project may not have been created correctly. Please check the IT software update projects and try again.'; await addErrorCommentToTask(task_gid, errorMessage); log('=== SCRIPT END (no instantiation - project not found from job) ==='); return; } // 9) Set project color & icon based on project name if (typeof projectsApiInstance === 'undefined') { log('projectsApiInstance is not available; cannot update project color or icon.'); } else { const finalProjectName = newProjectName || projectName; const color = pickColorFromProjectName(finalProjectName); const icon = pickIconFromProjectName(finalProjectName); const updateBody = { data: { color, icon } }; log(`Updating new project ${newProjectGid} with color="${color}" and icon="${icon}"`); // IMPORTANT: body first, then project_gid (aligned with Script Actions Gallery example) const rawUpdatedProject = await projectsApiInstance.updateProject(updateBody, newProjectGid); const safeData = rawUpdatedProject && rawUpdatedProject.data ? rawUpdatedProject.data : rawUpdatedProject; log('Project color/icon updated summary: ' + JSON.stringify({ gid: safeData?.gid || newProjectGid, color: safeData?.color, icon: safeData?.icon })); } // 10) Prefix the request task name const currentName = task.name || ''; const desiredName = currentName.startsWith(TASK_PREFIX) ? currentName : (TASK_PREFIX + currentName); if (desiredName !== currentName) { log('Updating request task name with prefix...'); const updateTaskBody = { data: { name: desiredName } }; await tasksApiInstance.updateTask(updateTaskBody, task_gid, {}); log('Task name updated: ' + desiredName); } else { log('Task name already has prefix; skipping rename.'); } // 11) Multi-home the request task into the created project log(`Multi-homing request task into new project (${newProjectGid})...`); if (typeof tasksApiInstance === 'undefined') { log('tasksApiInstance is not available; cannot multi-home the task into the new project.'); } else { // Body-first convention, aligned avec d'autres scripts qui fonctionnent ici const addProjectBody = { data: { project: newProjectGid } }; await tasksApiInstance.addProjectForTask(addProjectBody, task_gid, {}); log('Task successfully added to the new project.'); } // 12) Add a comment on the request task with project name + link if (typeof storiesApiInstance === 'undefined') { log('storiesApiInstance is not available; cannot add a success comment.'); log('=== SCRIPT END (success but no comment) ==='); return; } const linkLine = newProjectUrl ? `🔗 Project link: ${newProjectUrl}` : '🔗 Project link: (not available)'; const commentText = `✅ Project created from this request\n\n` + `📌 Project: ${newProjectName || '(unknown)'}\n` + `${linkLine}\n\n` + `👥 Roles\n` + `🧑‍🍳 PM: ${pmPerson.name}\n` + `🧑‍💻 Tech: ${techPerson.name}\n\n` + `📎 This request was added to the project (multi-homed).`; log('Adding comment to the request task:\n' + commentText); await storiesApiInstance.createStoryForTask( { data: { text: commentText } }, task_gid ); log('Comment added successfully.'); log('=== SCRIPT END (success) ==='); } run();

Une fois que cela fonctionne pour une équipe, l’adapter consiste principalement à remplacer le projet, les rôles et le modèle.