Kirjautuminen

Haku

Tehtävät

Kilpailu

Putka Open 2025
Kilpailu on päättynyt.

Keskustelu: Ohjelmointikysymykset: Jira Cloud REST API ja Rearch Issue

walkout_ [24.12.2025 17:17:31]

#

Hei,

Minulla on ongelma paginationissa listata Jira issueita. Ja Copilot ja ChatGPT ei osaa auttaa, koska sen näkemys on jäljessä nykyiseen API-dokumenataatioon.

https://stackoverflow.com/questions/79853409/jira-forge-api-search-issues

https://community.developer.atlassian.com/t/how-to-do-pagination-for-jira-issues/98009

Documentaatio:

https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-group-issue-search

Eli ei ole startAt vaan on nextPageToken ja limit. Eli requestin yhteydessä luodaan nextPageToken joka on string.

Voin maksaa auttajalle rahaakin jos hinnasta päästään sopimukseen. Ota yheyttä matti.kiviharju@i4ware.fi

walkout_ [24.12.2025 23:59:33]

#

Eli vielä.

NodeJS back-end:

export async function getIssues({
  project = null,
  year,
  month,
  limit = 50,
  issueKeys = null,
  pageToken = null,
} = {}) {
  if (typeof year !== 'number' || typeof month !== 'number') {
    throw new Error('getIssues requires { year: number, month: number }');
  }

  const safeLimit = Number(limit) && Number(limit) > 0 ? Math.min(Number(limit), 100) : 50;

  const dateCondition = 'updated >= -90d OR created >= -90d';
  const orderBy = 'ORDER BY created DESC';
  let jql = `${dateCondition} ${orderBy}`;

  if (issueKeys && Array.isArray(issueKeys) && issueKeys.length) {
    const escaped = issueKeys.map(k => `"${String(k).replace(/"/g, '\\"')}"`).join(', ');
    jql = `issuekey in (${escaped}) ${orderBy}`;
  } else if (project && project !== '-- ALL --') {
    jql = `project = "${project}" AND (${dateCondition}) ${orderBy}`;
  }

  console.log('getIssues JQL:', jql, 'pageToken:', pageToken, 'limit:', safeLimit);

  const url = route`/rest/api/3/search/jql`;
  let allIssues = [];
  let cursor = pageToken || null;

  try {
    const body = {
      expand: '',
      fields: ['*all'],
      fieldsByKeys: false,
      jql: jql,
      maxResults: safeLimit,
      nextPageToken: cursor || undefined,
      properties: []
    };

    console.log('Fetching issues with body:', JSON.stringify(body));

    const res = await api.asUser().requestJira(url, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    });

    if (res.status >= 400) {
      const text = await res.text();
      throw new Error(`Jira API error ${res.status}: ${text}`);
    }

    const json = await res.json();

    const issues = (json.issues || []).map(i => ({
      id: i.id,
      key: i.key,
      summary: i.fields.summary,
      avatarUrl: i.fields.issuetype.iconUrl,
      issueType: i.fields.issuetype.name,
      projectKey: i.fields.project.key
    }));

    allIssues.push(...issues);

    const nextToken = json.nextPageToken || null;

    // --- Compute worklogs per issue ---
    const daysInMonth = new Date(year, month + 1, 0).getDate();
    const fetchForIssue = async (iss) => {
      try {
        const wurl = route`/rest/api/3/issue/${iss.key}/worklog`;
        const wr = await api.asUser().requestJira(wurl, { method: 'GET', headers: { Accept: 'application/json' } });
        if (wr.status >= 400) return { iss, hoursByDay: Array(daysInMonth).fill(0), totalHours: 0 };
        const wjson = await wr.json();
        const hoursByDay = Array(daysInMonth).fill(0);
        (wjson.worklogs || []).forEach(w => {
          const iso = normalizeIsoTimezone(w.started || '');
          const d = new Date(iso);
          if (!isNaN(d.getTime()) && d.getFullYear() === year && d.getMonth() === month) {
            const dayIndex = d.getDate() - 1;
            const secs = Number(w.timeSpentSeconds) || 0;
            hoursByDay[dayIndex] += secs / 3600;
          }
        });
        for (let i = 0; i < hoursByDay.length; i++) hoursByDay[i] = +hoursByDay[i].toFixed(2);
        const totalHours = +hoursByDay.reduce((s, v) => s + v, 0).toFixed(2);
        return { iss, hoursByDay, totalHours };
      } catch {
        return { iss, hoursByDay: Array(daysInMonth).fill(0), totalHours: 0 };
      }
    };

    const results = await Promise.all(allIssues.map(fetchForIssue));
    const issuesWithHours = results.map(r => ({
      ...r.iss,
      hoursByDay: r.hoursByDay,
      totalHours: r.totalHours
    }));

    return {
      issues: issuesWithHours,
      pageToken: cursor,   // current token (used for prev)
      nextPageToken: nextToken,
      pageSize: safeLimit,
      total: issuesWithHours.length
    };

  } catch (err) {
    console.error('getIssues error:', err);
    throw err;
  }
}

Ja ReactJS front-end:

const fetchIssues = useCallback(async (token = null) => {
    const y = displayedMonth.getFullYear();
    const m = displayedMonth.getMonth(); // 0..11

    try {
      setLoadingIssues(true);

      // When using user's saved timesheet, pass explicit issue keys to backend
      // If using my timesheet but no keys saved, return empty list immediately
      if (useMyTimesheet && Array.isArray(myTimesheetKeys) && myTimesheetKeys.length === 0) {
        setIssues([]);
        setTotalIssues(0);
        setHours({});
        setLoadingIssues(false);
        return;
      }

      const result = await invoke('getIssues', {
        project: projectKey || null,
        year: y,
        month: m,
        limit: PAGE_SIZE,
        issueKeys: useMyTimesheet ? myTimesheetKeys : undefined,
        pageToken: token,
      });

      // Accept either { issues: [...] } or [...] directly
      const payload = Array.isArray(result)
        ? result
        : (result && Array.isArray(result.issues) ? result.issues : null);

      if (!payload) {
        console.warn('fetchIssues: invalid response', result);
        setIssues([]);
        setTotalIssues(0);
        return;
      }

      setIssues(payload);

      setNextPageToken(result.nextPageToken ?? null);

      if (token) {
        // push current token to stack for "prev" handling
        setPrevTokens(prev => [...prev, token]);
      }

      // If using saved personal timesheet selection, select all returned issues
      if (useMyTimesheet) {
        const sel = new Set();
        payload.forEach(i => sel.add(i.id));
        setSelectedIssues(sel);
      }

      // track total if provided
      setTotalIssues(result?.total ?? payload.length);

      // Populate hours map from backend hoursByDay data
      const newHours = {};
      payload.forEach((iss) => {
        newHours[iss.id] = iss.hoursByDay || Array(getDaysInMonth(y, m)).fill('');
      });
      setHours(newHours);

    } catch (error) {
      console.error('Error fetching issues:', error);
      showAlert('Failed to fetch issues: ' + (error.message || 'Unknown error'), 'danger');
      setIssues([]);
      setTotalIssues(0);
    } finally {
      setLoadingIssues(false);
    }
  }, [displayedMonth, projectKey, showAlert, useMyTimesheet, myTimesheetKeys]);

useEffect(() => {
    fetchIssues(null);
  }, [fetchIssues]);

  const handleNextPage = () => {
    if (nextPageToken) {
      fetchIssues(nextPageToken);
    }
  };

  const handlePrevPage = () => {
    if (prevTokens.length > 1) {
      // pop last token
      const newPrev = [...prevTokens];
      newPrev.pop();
      const token = newPrev[newPrev.length - 1];
      setPrevTokens(newPrev);
      fetchIssues(token);
    } else {
      // first page, no previous token
      fetchIssues(null);
      setPrevTokens([]);
    }
  };

<Button
                  className="page-btn-prev"
                  variant="light"
                  size="sm"
                  onClick={handlePrevPage}
                  disabled={page <= 1 || loadingIssues || useMyTimesheet}
                >{t('prevPage')}</Button>
                <Button
                  className="page-btn-next"
                  variant="light"
                  size="sm"
                  onClick={handleNextPage}
                  disabled={page * PAGE_SIZE >= totalIssues || loadingIssues || useMyTimesheet}
                >{t('nextPage')}</Button>

jlaire [27.12.2025 01:38:07]

#

walkout_ kirjoitti:

Minulla on ongelma paginationissa listata Jira issueita.
[...]
Eli ei ole startAt vaan on nextPageToken ja limit. Eli requestin yhteydessä luodaan nextPageToken joka on string.

Mikä ongelmasi on tarkemmin? Millaiseen kyselyyn saat odottamattoman vastauksen? Koodisi ilmeisesti yrittää käyttää nextPageTokeneita.

Voin auttaa hintaan 500e + 100e/h.

walkout_ [27.12.2025 13:23:01]

#

No siis vanhassa API:ssa oli muutujat startAt ja Limit.

Eli jos Limit oli 50 palautti se tietokannasta 50 riviä startAt kun oli 1 se palautti ensimmäiset 50 riviä ja senjälkeen UI antoi seuraavalle sivulle startAt-muuttujan palauttaa sivu 51-100, jne. Tämän vanha toimintalogiikka oli siis helppo koodata Jira Appsin UIlle. Ja nykyisestä API:sta on myös poistettu REST responsesta total josta sai arvon kuinka monta riviä on tietokannassa.

Nyt se toimii vain niin että annetaan maxResults vaikka 100 niin se antaa 100 riviä tietokannasta Order By DESC/ASC jokin sarake tietokannassa kuten ID (integer) tai created_at (date-time), nextPageTokeen on siis vain kilometrin pituinen HAS-string jossa siis G,ghjir.rtyrky8089kgh.gk tyyppinen merkkijono joka siis on muuttuja joka edustaa seuraan 100 rivin hakemista tietokannasta jos maxResults on 100. Ja pääongelma ja vaikeus on se että nextPageToken luodaan vasta kun REST API:n ensimmäinen POST/GET-request tehdään vain silloin kun kannassa on enemmän rivejä kuin maxResult-arvo integerinä. Ja sekään ei auta että Googlettaa jotain esimerkkikoodia vaikka Grovy-scriptillä kun siellä ei ole juuri sitä koodiesimerkkiä jota haen ja dokumentaatio linkin antaminen Tekoälylle ei auta koska se ymmärtä lukemansa väärin.

Toinen Ogelema on se että kaikki erilaiset AI:t kuten Copilot, ChatGPT, Claude, jne. ovat aina 1-4 vuotta jäljessä API-dokkien kanssa ja koodaa jonkun 1-4 vuotta vanhan dokumentaation mukaan jonka tänäpäivänä tekijä on jo muuttanut erilaiseksi. Ja tämä on yleistä monien hinojen Framawork API:ien kanssa.

Ma. StackOverflown ongelma on se että jos en osaa selittää ongelmaa tarpeeksi ymmärrettävästi niin minut bännätään.

Toi 500 € + 100 € / tunti on nykybudjettini ylitse kepeesti. Mutta ei se ulkomailta tilaaminen kaan aina ole halpa vaikka tekijä tekisi 5 € tunti koska jos se ei osaa tai ymmärrä heti alussa mikä on homma niin se pistää minulle 200 tunnin laskun.

Niin se sivutus Jira-issueille on hyvä olla koska jos niitä lataa yhdelle sivulle liian monta yhtäaikaa niin sepäs kaataa käyttäjän selaimen ja koko minun sovellus toimii niin hitaasti että sovellus ei jopa toimi ollenkaan. Samoin pitäisi olla bufferitoiminnallisuus niin että sitä mitä käyttäjä ei näe ei myöskään ole käyttäjän tietokoneen keskusmuistissa. Ongelma tulee varsinkin siinä vaiheessa kun UI:ssa on samalla sivulla liikaa erilaisia täytettäviä lomakekenttiä kuten erilaisia etsitextfieldejä, jne. niin niihin ei pysty kirjoittaan mitään kun samassa syyssä on liika dataa.

walkout_ [27.12.2025 13:57:29]

#

Pyysin ChatGPT:tä tekemään ymmärrettävämän version asiasta:

https://community.developer.atlassian.com/t/how-to-do-pagination-for-jira-issues/98009

I'm building a Jira Forge App and I need to fetch issues using the Jira Cloud REST API. In the old API, pagination used startAt, maxResults, and total, which made it easy to implement page navigation in the UI:

startAt = the first issue index

maxResults = number of issues per request

total = total number of issues

Now, the new API uses nextPageToken and no longer returns total. The response looks like this:

{
  "issues": [ ... ],
  "maxResults": 100,
  "nextPageToken": "G,ghjir.rtyrky8089kgh.gk"
}

maxResults works as before, e.g., 100 issues per request

nextPageToken is an opaque string used for fetching the next batch

total is no longer available

Problem: I need to implement a traditional page navigation in my Forge App UI (like page 1, 2, 3…), but without total and with the opaque nextPageToken, I cannot calculate the number of pages. Also, nextPageToken is only generated when there are more issues than maxResults.

What I tried:

Looping over requests using nextPageToken works to fetch all issues, but I cannot map it to page numbers in the UI.

Fetching a very large maxResults (e.g., 1000) works, but it may be inefficient.

Question: How can I implement UI pagination in a Jira Forge App using the new Jira Cloud REST API with nextPageToken? Is there a recommended pattern for mapping nextPageToken to page numbers, or should I switch to infinite scroll / lazy loading instead?

Muutta tässäkin on nyt ongelma että, koska on request ja response niin ChatGPT ei ymmärtänyt vieläkään kokonaisuutta.

Esnimmäinen POST-reguest JSON-bodyllä:

{
  "maxResults": 100,
  "nextPageToken": null
}

Joka antaa responsen jos issueta on enemmän kuin mitä on maxResults arvo:

{
  "issues": [ ... ],
  "nextPageToken": "G,ghjir.rtyrky8089kgh.gk"
}

Ja responsen jos issueita on vähemmän kuin maxResults arvo:

{
  "issues": [ ... ],
  "nextPageToken": null
}

Kun ensimmäisen requestin jälkeen saadaan jokin nextPageToken niin se pitää olla seuraavaan sivun issueiden listaamiseen ReactUIssa jossain panikeessa jota panamalla tehdää REST API request POST jonka Body on JSON:

{
  "maxResults": 100,
  "nextPageToken": "G,ghjir.rtyrky8089kgh.gk"
}

Ja koska AIt eivät ole ajantasalla viimeisimpien API-dokkien kanssa ja vanha on depreaced/removed niin Copilot Agentti joka tekee suoraan muutoksisa kovalevyllä oleviin tiedostohin Node.js ja React puolella niin sepäs koodaa tiedostot päin prinkaalaa.

walkout_ [27.12.2025 15:07:01]

#

Niin ja voi olla että saan ite ongelman korjattua mutta siihen menee aikaa ja jätän AIt väliin.

walkout_ [27.12.2025 17:26:31]

#

Sain korjattua. Meni vain paljon aikaa keksiä miten saan AIn antamaan edes toimivan koodin. Eli olen käsittänyt että Prev Page -nappia ei ole enää mahdollista kodata koska nextPageToken ei voida talentaa mm. välimuistiin koska prevPageTokenia ei ole eikä ole mahdollista tehdä toimintoja Page 1 / 50 Pages.

Ja tässä piloitettu video mitä oikeen yrititän koodaa: https://www.youtube.com/watch?v=T3cUg7MN71g

Got it finally work by mothod tell step-by-step instructions for ChatGPT (no one is updated LLM of OpenAI and other AIs to use latest changes to API made by Atlassian).

import api, { route } from “@forge/api”;

export const getIssues = async (payload) => {
const { jql, maxResults = 100, nextPageToken = null } = payload;

const body = {
jql,
maxResults,
nextPageToken
};

const response = await api.asUser().requestJira(
route/rest/api/3/search/jql,
{
method: “POST”,
headers: {
“Accept”: “application/json”,
“Content-Type”: “application/json”
},
body: JSON.stringify(body)
}
);

if (!response.ok) {
throw new Error(Jira API error: ${response.status});
}

const data = await response.json();

return {
issues: data.issues ||
,
nextPageToken: data.nextPageToken || null
};
};
import { useState } from "react";
import { invoke } from "@forge/bridge";

const MAX_RESULTS = 100;

export function IssuesView() {
  const [issues, setIssues] = useState([]);
  const [nextPageToken, setNextPageToken] = useState(null);
  const [loading, setLoading] = useState(false);

  const fetchIssues = async () => {
    setLoading(true);

    const response = await invoke("getIssues", {
      jql: "ORDER BY created DESC",
      maxResults: MAX_RESULTS,
      nextPageToken
    });

    setIssues(prev => [...prev, ...response.issues]);
    setNextPageToken(response.nextPageToken);

    setLoading(false);
  };

  return (
    <div>
      <h3>Issues</h3>

      <ul>
        {issues.map(issue => (
          <li key={issue.id}>
            {issue.key} – {issue.fields.summary}
          </li>
        ))}
      </ul>

      {nextPageToken && (
        <button onClick={fetchIssues} disabled={loading}>
          {loading ? "Loading..." : "Next page"}
        </button>
      )}

      {!nextPageToken && issues.length > 0 && (
        <p>No more issues.</p>
      )}
    </div>
  );
}

Vastaus

Muista lukea kirjoitusohjeet.
Tietoa sivustosta